feat: introduce phase configuration module and enhance agent profiles

This commit adds a new phase configuration module that manages model and thinking level settings for different execution phases. It reads configurations from `task_metadata.json` and provides resolved model IDs for various phases, including spec creation, planning, coding, and QA.

Key changes include:
- New `phase_config.py` file to handle model ID mappings and thinking budgets.
- Updates to agent files (`coder.py`, `planner.py`, `loop.py`) to utilize phase-specific models and thinking levels.
- Modifications to the CLI and UI components to support per-phase configuration, enhancing the user experience for task creation and editing.

The new structure allows for optimized model selection and thinking depth based on the phase, improving overall task execution efficiency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
AndyMik90
2025-12-19 10:24:44 +01:00
parent 569e921759
commit 26725286d5
26 changed files with 1493 additions and 131 deletions
@@ -128,6 +128,20 @@ export class AgentManager extends EventEmitter {
args.push('--auto-approve'); args.push('--auto-approve');
} }
// Pass model and thinking level configuration
// For auto profile, use phase-specific config; otherwise use single model/thinking
if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
// Pass the spec phase model and thinking level to spec_runner
args.push('--model', metadata.phaseModels.spec);
args.push('--thinking-level', metadata.phaseThinking.spec);
} else if (metadata?.model) {
// Non-auto profile: use single model and thinking level
args.push('--model', metadata.model);
if (metadata.thinkingLevel) {
args.push('--thinking-level', metadata.thinkingLevel);
}
}
// Store context for potential restart // Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata); this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata);
@@ -183,6 +197,8 @@ export class AgentManager extends EventEmitter {
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
// The options.parallel and options.workers are kept for future use or logging purposes // The options.parallel and options.workers are kept for future use or logging purposes
// Note: Model configuration is read from task_metadata.json by the Python scripts,
// which allows per-phase configuration for planner, coder, and QA phases
// Store context for potential restart // Store context for potential restart
this.storeTaskContext(taskId, projectPath, specId, options, false); this.storeTaskContext(taskId, projectPath, specId, options, false);
+17
View File
@@ -48,6 +48,23 @@ export interface TaskExecutionOptions {
export interface SpecCreationMetadata { export interface SpecCreationMetadata {
requireReviewBeforeCoding?: boolean; requireReviewBeforeCoding?: boolean;
// Auto profile - phase-based model and thinking configuration
isAutoProfile?: boolean;
phaseModels?: {
spec: 'haiku' | 'sonnet' | 'opus';
planning: 'haiku' | 'sonnet' | 'opus';
coding: 'haiku' | 'sonnet' | 'opus';
qa: 'haiku' | 'sonnet' | 'opus';
};
phaseThinking?: {
spec: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
};
// Non-auto profile - single model and thinking level
model?: 'haiku' | 'sonnet' | 'opus';
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
} }
export interface IdeationProgressData { export interface IdeationProgressData {
@@ -0,0 +1,370 @@
/**
* AgentProfileSelector - Reusable component for selecting agent profile in forms
*
* Provides a dropdown for quick profile selection (Auto, Complex, Balanced, Quick)
* with an inline "Custom" option that reveals model and thinking level selects.
* The "Auto" profile shows per-phase model configuration.
*
* Used in TaskCreationWizard and TaskEditDialog.
*/
import { useState } from 'react';
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import { Label } from './ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from './ui/select';
import {
DEFAULT_AGENT_PROFILES,
AVAILABLE_MODELS,
THINKING_LEVELS,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
} from '../../shared/constants';
import type { ModelType, ThinkingLevel } from '../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
import { cn } from '../lib/utils';
interface AgentProfileSelectorProps {
/** Currently selected profile ID ('auto', 'complex', 'balanced', 'quick', or 'custom') */
profileId: string;
/** Current model value (fallback for non-auto profiles) */
model: ModelType | '';
/** Current thinking level value (fallback for non-auto profiles) */
thinkingLevel: ThinkingLevel | '';
/** Phase model configuration (for auto profile) */
phaseModels?: PhaseModelConfig;
/** Phase thinking configuration (for auto profile) */
phaseThinking?: PhaseThinkingConfig;
/** Called when profile selection changes */
onProfileChange: (profileId: string, model: ModelType, thinkingLevel: ThinkingLevel) => void;
/** Called when model changes (in custom mode) */
onModelChange: (model: ModelType) => void;
/** Called when thinking level changes (in custom mode) */
onThinkingLevelChange: (level: ThinkingLevel) => void;
/** Called when phase models change (in auto mode) */
onPhaseModelsChange?: (phaseModels: PhaseModelConfig) => void;
/** Called when phase thinking changes (in auto mode) */
onPhaseThinkingChange?: (phaseThinking: PhaseThinkingConfig) => void;
/** Whether the selector is disabled */
disabled?: boolean;
}
const iconMap: Record<string, React.ElementType> = {
Brain,
Scale,
Zap,
Sparkles
};
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' },
planning: { label: 'Planning', description: 'Implementation planning and architecture' },
coding: { label: 'Coding', description: 'Actual code implementation' },
qa: { label: 'QA Review', description: 'Quality assurance and validation' }
};
export function AgentProfileSelector({
profileId,
model,
thinkingLevel,
phaseModels,
phaseThinking,
onProfileChange,
onModelChange,
onThinkingLevelChange,
onPhaseModelsChange,
onPhaseThinkingChange,
disabled
}: AgentProfileSelectorProps) {
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
const isCustom = profileId === 'custom';
const isAuto = profileId === 'auto';
// Use provided phase configs or defaults
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
const currentPhaseThinking = phaseThinking || DEFAULT_PHASE_THINKING;
const handleProfileSelect = (selectedId: string) => {
if (selectedId === 'custom') {
// Keep current model/thinking level, just mark as custom
onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium');
} else if (selectedId === 'auto') {
// Auto profile - set defaults
const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto');
if (autoProfile) {
onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel);
// Initialize phase configs with defaults if callback provided
if (onPhaseModelsChange && autoProfile.phaseModels) {
onPhaseModelsChange(autoProfile.phaseModels);
}
if (onPhaseThinkingChange && autoProfile.phaseThinking) {
onPhaseThinkingChange(autoProfile.phaseThinking);
}
}
} else {
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId);
if (profile) {
onProfileChange(profile.id, profile.model, profile.thinkingLevel);
}
}
};
const handlePhaseModelChange = (phase: keyof PhaseModelConfig, value: ModelType) => {
if (onPhaseModelsChange) {
onPhaseModelsChange({
...currentPhaseModels,
[phase]: value
});
}
};
const handlePhaseThinkingChange = (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
if (onPhaseThinkingChange) {
onPhaseThinkingChange({
...currentPhaseThinking,
[phase]: value
});
}
};
// Get profile display info
const getProfileDisplay = () => {
if (isCustom) {
return {
icon: Sliders,
label: 'Custom Configuration',
description: 'Choose model & thinking level'
};
}
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
if (profile) {
return {
icon: iconMap[profile.icon || 'Scale'] || Scale,
label: profile.name,
description: profile.description
};
}
// Default to balanced
return {
icon: Scale,
label: 'Balanced',
description: 'Good balance of speed and quality'
};
};
const display = getProfileDisplay();
return (
<div className="space-y-4">
{/* Agent Profile Selection */}
<div className="space-y-2">
<Label htmlFor="agent-profile" className="text-sm font-medium text-foreground">
Agent Profile
</Label>
<Select
value={profileId}
onValueChange={handleProfileSelect}
disabled={disabled}
>
<SelectTrigger id="agent-profile" className="h-10">
<SelectValue>
<div className="flex items-center gap-2">
<display.icon className="h-4 w-4" />
<span>{display.label}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{DEFAULT_AGENT_PROFILES.map((profile) => {
const ProfileIcon = iconMap[profile.icon || 'Scale'] || Scale;
const modelLabel = AVAILABLE_MODELS.find(m => m.value === profile.model)?.label;
return (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex items-center gap-2">
<ProfileIcon className="h-4 w-4 shrink-0" />
<div>
<span className="font-medium">{profile.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
{profile.isAutoProfile
? '(per-phase optimization)'
: `(${modelLabel} + ${profile.thinkingLevel})`
}
</span>
</div>
</div>
</SelectItem>
);
})}
<SelectItem value="custom">
<div className="flex items-center gap-2">
<Sliders className="h-4 w-4 shrink-0" />
<div>
<span className="font-medium">Custom</span>
<span className="ml-2 text-xs text-muted-foreground">
(Choose model & thinking level)
</span>
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{display.description}
</p>
</div>
{/* Auto Profile - Phase Configuration */}
{isAuto && (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
{/* Phase Summary */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
className={cn(
'flex w-full items-center justify-between text-sm',
'text-muted-foreground hover:text-foreground transition-colors'
)}
disabled={disabled}
>
<span className="font-medium text-foreground">Phase Configuration</span>
{showPhaseDetails ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Compact summary when collapsed */}
{!showPhaseDetails && (
<div className="grid grid-cols-2 gap-2 text-xs">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => {
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase];
return (
<div key={phase} className="flex items-center justify-between rounded bg-background/50 px-2 py-1">
<span className="text-muted-foreground">{PHASE_LABELS[phase].label}:</span>
<span className="font-medium">{modelLabel}</span>
</div>
);
})}
</div>
)}
</div>
{/* Detailed Phase Configuration */}
{showPhaseDetails && (
<div className="space-y-4 pt-2">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
<div key={phase} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
{PHASE_LABELS[phase].label}
</Label>
<span className="text-xs text-muted-foreground">
{PHASE_LABELS[phase].description}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={currentPhaseModels[phase]}
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Custom Configuration (shown only when custom is selected) */}
{isCustom && (
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-4">
{/* Model Selection */}
<div className="space-y-2">
<Label htmlFor="custom-model" className="text-xs font-medium text-muted-foreground">
Model
</Label>
<Select
value={model}
onValueChange={(value) => onModelChange(value as ModelType)}
disabled={disabled}
>
<SelectTrigger id="custom-model" className="h-9">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Thinking Level Selection */}
<div className="space-y-2">
<Label htmlFor="custom-thinking" className="text-xs font-medium text-muted-foreground">
Thinking Level
</Label>
<Select
value={thinkingLevel}
onValueChange={(value) => onThinkingLevelChange(value as ThinkingLevel)}
disabled={disabled}
>
<SelectTrigger id="custom-thinking" className="h-9">
<SelectValue placeholder="Select thinking level" />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
<div className="flex items-center gap-2">
<span>{level.label}</span>
<span className="text-xs text-muted-foreground">
- {level.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
);
}
@@ -40,10 +40,12 @@ import {
} from './ImageUpload'; } from './ImageUpload';
import { ReferencedFilesSection } from './ReferencedFilesSection'; import { ReferencedFilesSection } from './ReferencedFilesSection';
import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer'; import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer';
import { AgentProfileSelector } from './AgentProfileSelector';
import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store'; import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store';
import { useProjectStore } from '../stores/project-store'; import { useProjectStore } from '../stores/project-store';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types'; import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
import { import {
TASK_CATEGORY_LABELS, TASK_CATEGORY_LABELS,
TASK_PRIORITY_LABELS, TASK_PRIORITY_LABELS,
@@ -53,8 +55,8 @@ import {
MAX_REFERENCED_FILES, MAX_REFERENCED_FILES,
ALLOWED_IMAGE_TYPES_DISPLAY, ALLOWED_IMAGE_TYPES_DISPLAY,
DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILES,
AVAILABLE_MODELS, DEFAULT_PHASE_MODELS,
THINKING_LEVELS DEFAULT_PHASE_THINKING
} from '../../shared/constants'; } from '../../shared/constants';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
@@ -73,7 +75,7 @@ export function TaskCreationWizard({
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const selectedProfile = DEFAULT_AGENT_PROFILES.find( const selectedProfile = DEFAULT_AGENT_PROFILES.find(
p => p.id === settings.selectedAgentProfile p => p.id === settings.selectedAgentProfile
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'balanced')!; ) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@@ -97,8 +99,16 @@ export function TaskCreationWizard({
const [impact, setImpact] = useState<TaskImpact | ''>(''); const [impact, setImpact] = useState<TaskImpact | ''>('');
// Model configuration (initialized from selected agent profile) // Model configuration (initialized from selected agent profile)
const [profileId, setProfileId] = useState<string>(settings.selectedAgentProfile || 'auto');
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model); const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel); const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
// Auto profile - per-phase configuration
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
);
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
);
// Image attachments // Image attachments
const [images, setImages] = useState<ImageAttachment[]>([]); const [images, setImages] = useState<ImageAttachment[]>([]);
@@ -161,9 +171,12 @@ export function TaskCreationWizard({
setPriority(draft.priority); setPriority(draft.priority);
setComplexity(draft.complexity); setComplexity(draft.complexity);
setImpact(draft.impact); setImpact(draft.impact);
// Load model/thinkingLevel from draft if present, otherwise use profile defaults // Load model/thinkingLevel/profileId from draft if present, otherwise use profile defaults
setProfileId(draft.profileId || settings.selectedAgentProfile || 'balanced');
setModel(draft.model || selectedProfile.model); setModel(draft.model || selectedProfile.model);
setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel); setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel);
setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
setImages(draft.images); setImages(draft.images);
setReferencedFiles(draft.referencedFiles ?? []); setReferencedFiles(draft.referencedFiles ?? []);
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false); setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
@@ -178,12 +191,15 @@ export function TaskCreationWizard({
} }
// Note: Referenced Files section is always visible, no need to expand // Note: Referenced Files section is always visible, no need to expand
} else { } else {
// No draft - initialize model/thinkingLevel from selected profile // No draft - initialize from selected profile
setProfileId(settings.selectedAgentProfile || 'balanced');
setModel(selectedProfile.model); setModel(selectedProfile.model);
setThinkingLevel(selectedProfile.thinkingLevel); setThinkingLevel(selectedProfile.thinkingLevel);
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
} }
} }
}, [open, projectId, selectedProfile.model, selectedProfile.thinkingLevel]); }, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
/** /**
* Get current form state as a draft * Get current form state as a draft
@@ -196,13 +212,16 @@ export function TaskCreationWizard({
priority, priority,
complexity, complexity,
impact, impact,
profileId,
model, model,
thinkingLevel, thinkingLevel,
phaseModels,
phaseThinking,
images, images,
referencedFiles, referencedFiles,
requireReviewBeforeCoding, requireReviewBeforeCoding,
savedAt: new Date() savedAt: new Date()
}), [projectId, title, description, category, priority, complexity, impact, model, thinkingLevel, images, referencedFiles, requireReviewBeforeCoding]); }), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]);
/** /**
* Handle paste event for screenshot support * Handle paste event for screenshot support
*/ */
@@ -532,6 +551,12 @@ export function TaskCreationWizard({
if (impact) metadata.impact = impact; if (impact) metadata.impact = impact;
if (model) metadata.model = model; if (model) metadata.model = model;
if (thinkingLevel) metadata.thinkingLevel = thinkingLevel; if (thinkingLevel) metadata.thinkingLevel = thinkingLevel;
// Auto profile - per-phase configuration
if (profileId === 'auto') {
metadata.isAutoProfile = true;
if (phaseModels) metadata.phaseModels = phaseModels;
if (phaseThinking) metadata.phaseThinking = phaseThinking;
}
if (images.length > 0) metadata.attachedImages = images; if (images.length > 0) metadata.attachedImages = images;
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles; if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true; if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
@@ -561,9 +586,12 @@ export function TaskCreationWizard({
setPriority(''); setPriority('');
setComplexity(''); setComplexity('');
setImpact(''); setImpact('');
// Reset model/thinkingLevel to selected profile defaults // Reset to selected profile defaults
setProfileId(settings.selectedAgentProfile || 'balanced');
setModel(selectedProfile.model); setModel(selectedProfile.model);
setThinkingLevel(selectedProfile.thinkingLevel); setThinkingLevel(selectedProfile.thinkingLevel);
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
setImages([]); setImages([]);
setReferencedFiles([]); setReferencedFiles([]);
setRequireReviewBeforeCoding(false); setRequireReviewBeforeCoding(false);
@@ -765,57 +793,24 @@ export function TaskCreationWizard({
</p> </p>
</div> </div>
{/* Model Selection */} {/* Agent Profile Selection */}
<div className="space-y-2"> <AgentProfileSelector
<Label htmlFor="model" className="text-sm font-medium text-foreground"> profileId={profileId}
Model model={model}
</Label> thinkingLevel={thinkingLevel}
<Select phaseModels={phaseModels}
value={model} phaseThinking={phaseThinking}
onValueChange={(value) => setModel(value as ModelType)} onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
disabled={isCreating} setProfileId(newProfileId);
> setModel(newModel);
<SelectTrigger id="model" className="h-9"> setThinkingLevel(newThinkingLevel);
<SelectValue placeholder="Select model" /> }}
</SelectTrigger> onModelChange={setModel}
<SelectContent> onThinkingLevelChange={setThinkingLevel}
{AVAILABLE_MODELS.map((m) => ( onPhaseModelsChange={setPhaseModels}
<SelectItem key={m.value} value={m.value}> onPhaseThinkingChange={setPhaseThinking}
{m.label} disabled={isCreating}
</SelectItem> />
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The Claude model to use for this task. Defaults to your selected agent profile.
</p>
</div>
{/* Thinking Level Selection */}
<div className="space-y-2">
<Label htmlFor="thinking-level" className="text-sm font-medium text-foreground">
Thinking Level
</Label>
<Select
value={thinkingLevel}
onValueChange={(value) => setThinkingLevel(value as ThinkingLevel)}
disabled={isCreating}
>
<SelectTrigger id="thinking-level" className="h-9">
<SelectValue placeholder="Select thinking level" />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Extended thinking depth for complex reasoning. Higher levels use more tokens but provide deeper analysis.
</p>
</div>
{/* Paste Success Indicator */} {/* Paste Success Indicator */}
{pasteSuccess && ( {pasteSuccess && (
@@ -54,17 +54,23 @@ import {
isValidImageMimeType, isValidImageMimeType,
resolveFilename resolveFilename
} from './ImageUpload'; } from './ImageUpload';
import { AgentProfileSelector } from './AgentProfileSelector';
import { persistUpdateTask } from '../stores/task-store'; import { persistUpdateTask } from '../stores/task-store';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types'; import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types';
import { import {
TASK_CATEGORY_LABELS, TASK_CATEGORY_LABELS,
TASK_PRIORITY_LABELS, TASK_PRIORITY_LABELS,
TASK_COMPLEXITY_LABELS, TASK_COMPLEXITY_LABELS,
TASK_IMPACT_LABELS, TASK_IMPACT_LABELS,
MAX_IMAGES_PER_TASK, MAX_IMAGES_PER_TASK,
ALLOWED_IMAGE_TYPES_DISPLAY ALLOWED_IMAGE_TYPES_DISPLAY,
DEFAULT_AGENT_PROFILES,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
} from '../../shared/constants'; } from '../../shared/constants';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
import { useSettingsStore } from '../stores/settings-store';
/** /**
* Props for the TaskEditDialog component * Props for the TaskEditDialog component
@@ -81,6 +87,12 @@ interface TaskEditDialogProps {
} }
export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) { export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) {
// Get selected agent profile from settings for defaults
const { settings } = useSettingsStore();
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
p => p.id === settings.selectedAgentProfile
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
const [title, setTitle] = useState(task.title); const [title, setTitle] = useState(task.title);
const [description, setDescription] = useState(task.description); const [description, setDescription] = useState(task.description);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -95,6 +107,36 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || ''); const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || ''); const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
// Agent profile / model configuration
const [profileId, setProfileId] = useState<string>(() => {
// Check if task uses Auto profile
if (task.metadata?.isAutoProfile) {
return 'auto';
}
// Determine profile ID from task metadata or default to 'auto'
const taskModel = task.metadata?.model;
const taskThinking = task.metadata?.thinkingLevel;
if (taskModel && taskThinking) {
// Check if it matches a known profile
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
);
return matchingProfile?.id || 'custom';
}
return settings.selectedAgentProfile || 'auto';
});
const [model, setModel] = useState<ModelType | ''>(task.metadata?.model || selectedProfile.model);
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(
task.metadata?.thinkingLevel || selectedProfile.thinkingLevel
);
// Auto profile - per-phase configuration
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
task.metadata?.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
);
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
task.metadata?.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
);
// Image attachments // Image attachments
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []); const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
@@ -118,6 +160,35 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
setPriority(task.metadata?.priority || ''); setPriority(task.metadata?.priority || '');
setComplexity(task.metadata?.complexity || ''); setComplexity(task.metadata?.complexity || '');
setImpact(task.metadata?.impact || ''); setImpact(task.metadata?.impact || '');
// Reset model configuration
const taskModel = task.metadata?.model;
const taskThinking = task.metadata?.thinkingLevel;
const isAutoProfile = task.metadata?.isAutoProfile;
if (isAutoProfile) {
setProfileId('auto');
setModel(taskModel || selectedProfile.model);
setThinkingLevel(taskThinking || selectedProfile.thinkingLevel);
setPhaseModels(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
} else if (taskModel && taskThinking) {
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
);
setProfileId(matchingProfile?.id || 'custom');
setModel(taskModel);
setThinkingLevel(taskThinking);
setPhaseModels(DEFAULT_PHASE_MODELS);
setPhaseThinking(DEFAULT_PHASE_THINKING);
} else {
setProfileId(settings.selectedAgentProfile || 'auto');
setModel(selectedProfile.model);
setThinkingLevel(selectedProfile.thinkingLevel);
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
}
setImages(task.metadata?.attachedImages || []); setImages(task.metadata?.attachedImages || []);
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false); setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
setError(null); setError(null);
@@ -130,7 +201,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
setShowImages((task.metadata?.attachedImages || []).length > 0); setShowImages((task.metadata?.attachedImages || []).length > 0);
setPasteSuccess(false); setPasteSuccess(false);
} }
}, [open, task]); }, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
/** /**
* Handle paste event for screenshot support * Handle paste event for screenshot support
@@ -328,6 +399,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
priority !== (task.metadata?.priority || '') || priority !== (task.metadata?.priority || '') ||
complexity !== (task.metadata?.complexity || '') || complexity !== (task.metadata?.complexity || '') ||
impact !== (task.metadata?.impact || '') || impact !== (task.metadata?.impact || '') ||
model !== (task.metadata?.model || '') ||
thinkingLevel !== (task.metadata?.thinkingLevel || '') ||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) || requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []); JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
@@ -346,6 +419,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
if (priority) metadataUpdates.priority = priority; if (priority) metadataUpdates.priority = priority;
if (complexity) metadataUpdates.complexity = complexity; if (complexity) metadataUpdates.complexity = complexity;
if (impact) metadataUpdates.impact = impact; if (impact) metadataUpdates.impact = impact;
if (model) metadataUpdates.model = model as ModelType;
if (thinkingLevel) metadataUpdates.thinkingLevel = thinkingLevel as ThinkingLevel;
// Auto profile - per-phase configuration
if (profileId === 'auto') {
metadataUpdates.isAutoProfile = true;
if (phaseModels) metadataUpdates.phaseModels = phaseModels;
if (phaseThinking) metadataUpdates.phaseThinking = phaseThinking;
} else {
// Clear auto profile fields if switching away from auto
metadataUpdates.isAutoProfile = false;
}
if (images.length > 0) metadataUpdates.attachedImages = images; if (images.length > 0) metadataUpdates.attachedImages = images;
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding; metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
@@ -429,6 +513,25 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
</p> </p>
</div> </div>
{/* Agent Profile Selection */}
<AgentProfileSelector
profileId={profileId}
model={model}
thinkingLevel={thinkingLevel}
phaseModels={phaseModels}
phaseThinking={phaseThinking}
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
setProfileId(newProfileId);
setModel(newModel);
setThinkingLevel(newThinkingLevel);
}}
onModelChange={setModel}
onThinkingLevelChange={setThinkingLevel}
onPhaseModelsChange={setPhaseModels}
onPhaseThinkingChange={setPhaseThinking}
disabled={isSaving}
/>
{/* Paste Success Indicator */} {/* Paste Success Indicator */}
{pasteSuccess && ( {pasteSuccess && (
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200"> <div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
@@ -14,12 +14,15 @@ import {
Search, Search,
FolderSearch, FolderSearch,
Wrench, Wrench,
Info Info,
Brain,
Cpu
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry } from '../../../shared/types'; import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry, TaskMetadata } from '../../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig, ThinkingLevel, ModelTypeShort } from '../../../shared/types/settings';
interface TaskLogsProps { interface TaskLogsProps {
task: Task; task: Task;
@@ -51,6 +54,60 @@ const PHASE_COLORS: Record<TaskLogPhase, string> = {
validation: 'text-purple-500 bg-purple-500/10 border-purple-500/30' validation: 'text-purple-500 bg-purple-500/10 border-purple-500/30'
}; };
// Map log phases to config phase keys
// Note: 'planning' log phase covers both spec creation and implementation planning
const LOG_PHASE_TO_CONFIG_PHASE: Record<TaskLogPhase, keyof PhaseModelConfig> = {
planning: 'spec', // Planning log phase primarily shows spec creation
coding: 'coding',
validation: 'qa'
};
// Short labels for models
const MODEL_SHORT_LABELS: Record<ModelTypeShort, string> = {
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku'
};
// Short labels for thinking levels
const THINKING_SHORT_LABELS: Record<ThinkingLevel, string> = {
none: 'None',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra'
};
// Helper to get model and thinking info for a log phase
function getPhaseConfig(
metadata: TaskMetadata | undefined,
logPhase: TaskLogPhase
): { model: string; thinking: string } | null {
if (!metadata) return null;
const configPhase = LOG_PHASE_TO_CONFIG_PHASE[logPhase];
// Auto profile with per-phase config
if (metadata.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
const model = metadata.phaseModels[configPhase];
const thinking = metadata.phaseThinking[configPhase];
return {
model: MODEL_SHORT_LABELS[model] || model,
thinking: THINKING_SHORT_LABELS[thinking] || thinking
};
}
// Non-auto profile with single model/thinking
if (metadata.model && metadata.thinkingLevel) {
return {
model: MODEL_SHORT_LABELS[metadata.model] || metadata.model,
thinking: THINKING_SHORT_LABELS[metadata.thinkingLevel] || metadata.thinkingLevel
};
}
return null;
}
export function TaskLogs({ export function TaskLogs({
task, task,
phaseLogs, phaseLogs,
@@ -84,6 +141,7 @@ export function TaskLogs({
isExpanded={expandedPhases.has(phase)} isExpanded={expandedPhases.has(phase)}
onToggle={() => onTogglePhase(phase)} onToggle={() => onTogglePhase(phase)}
isTaskStuck={isStuck} isTaskStuck={isStuck}
phaseConfig={getPhaseConfig(task.metadata, phase)}
/> />
))} ))}
<div ref={logsEndRef} /> <div ref={logsEndRef} />
@@ -113,9 +171,10 @@ interface PhaseLogSectionProps {
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
isTaskStuck?: boolean; isTaskStuck?: boolean;
phaseConfig?: { model: string; thinking: string } | null;
} }
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }: PhaseLogSectionProps) { function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, phaseConfig }: PhaseLogSectionProps) {
const Icon = PHASE_ICONS[phase]; const Icon = PHASE_ICONS[phase];
const status = phaseLog?.status || 'pending'; const status = phaseLog?.status || 'pending';
const hasEntries = (phaseLog?.entries.length || 0) > 0; const hasEntries = (phaseLog?.entries.length || 0) > 0;
@@ -190,7 +249,23 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }:
</span> </span>
)} )}
</div> </div>
{getStatusBadge()} <div className="flex items-center gap-2">
{/* Model and thinking level indicator */}
{phaseConfig && (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<div className="flex items-center gap-0.5" title={`Model: ${phaseConfig.model}`}>
<Cpu className="h-3 w-3" />
<span>{phaseConfig.model}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<div className="flex items-center gap-0.5" title={`Thinking: ${phaseConfig.thinking}`}>
<Brain className="h-3 w-3" />
<span>{phaseConfig.thinking}</span>
</div>
</div>
)}
{getStatusBadge()}
</div>
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
+52 -28
View File
@@ -48,69 +48,93 @@ export function useIpcListeners(): void {
); );
// Roadmap event listeners // Roadmap event listeners
const setGenerationStatus = useRoadmapStore.getState().setGenerationStatus; // Helper to check if event is for the currently viewed project
const setRoadmap = useRoadmapStore.getState().setRoadmap; const isCurrentProject = (eventProjectId: string): boolean => {
const currentProjectId = useRoadmapStore.getState().currentProjectId;
return currentProjectId === eventProjectId;
};
const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress( const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress(
(_projectId: string, status: RoadmapGenerationStatus) => { (projectId: string, status: RoadmapGenerationStatus) => {
// Debug logging // Debug logging
if (window.DEBUG) { if (window.DEBUG) {
console.log('[Roadmap] Progress update:', { console.log('[Roadmap] Progress update:', {
projectId: _projectId, projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId,
phase: status.phase, phase: status.phase,
progress: status.progress, progress: status.progress,
message: status.message message: status.message
}); });
} }
setGenerationStatus(status); // Only update if this is for the currently viewed project
if (isCurrentProject(projectId)) {
useRoadmapStore.getState().setGenerationStatus(status);
}
} }
); );
const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete( const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete(
(_projectId: string, roadmap: Roadmap) => { (projectId: string, roadmap: Roadmap) => {
// Debug logging // Debug logging
if (window.DEBUG) { if (window.DEBUG) {
console.log('[Roadmap] Generation complete:', { console.log('[Roadmap] Generation complete:', {
projectId: _projectId, projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId,
featuresCount: roadmap.features?.length || 0, featuresCount: roadmap.features?.length || 0,
phasesCount: roadmap.phases?.length || 0 phasesCount: roadmap.phases?.length || 0
}); });
} }
setRoadmap(roadmap); // Only update if this is for the currently viewed project
setGenerationStatus({ if (isCurrentProject(projectId)) {
phase: 'complete', useRoadmapStore.getState().setRoadmap(roadmap);
progress: 100, useRoadmapStore.getState().setGenerationStatus({
message: 'Roadmap ready' phase: 'complete',
}); progress: 100,
message: 'Roadmap ready'
});
}
} }
); );
const cleanupRoadmapError = window.electronAPI.onRoadmapError( const cleanupRoadmapError = window.electronAPI.onRoadmapError(
(_projectId: string, error: string) => { (projectId: string, error: string) => {
// Debug logging // Debug logging
if (window.DEBUG) { if (window.DEBUG) {
console.error('[Roadmap] Error received:', { projectId: _projectId, error }); console.error('[Roadmap] Error received:', {
projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId,
error
});
}
// Only update if this is for the currently viewed project
if (isCurrentProject(projectId)) {
useRoadmapStore.getState().setGenerationStatus({
phase: 'error',
progress: 0,
message: 'Generation failed',
error
});
} }
setGenerationStatus({
phase: 'error',
progress: 0,
message: 'Generation failed',
error
});
} }
); );
const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped( const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped(
(_projectId: string) => { (projectId: string) => {
// Debug logging // Debug logging
if (window.DEBUG) { if (window.DEBUG) {
console.log('[Roadmap] Generation stopped:', { projectId: _projectId }); console.log('[Roadmap] Generation stopped:', {
projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId
});
}
// Only update if this is for the currently viewed project
if (isCurrentProject(projectId)) {
useRoadmapStore.getState().setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
} }
setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
} }
); );
@@ -25,8 +25,8 @@ export const DEFAULT_APP_SETTINGS = {
// Global API keys (used as defaults for all projects) // Global API keys (used as defaults for all projects)
globalClaudeOAuthToken: undefined as string | undefined, globalClaudeOAuthToken: undefined as string | undefined,
globalOpenAIApiKey: undefined as string | undefined, globalOpenAIApiKey: undefined as string | undefined,
// Selected agent profile - defaults to 'balanced' for good speed/quality balance // Selected agent profile - defaults to 'auto' for per-phase optimized model selection
selectedAgentProfile: 'balanced', selectedAgentProfile: 'auto',
// Changelog preferences (persisted between sessions) // Changelog preferences (persisted between sessions)
changelogFormat: 'keep-a-changelog' as const, changelogFormat: 'keep-a-changelog' as const,
changelogAudience: 'user-facing' as const, changelogAudience: 'user-facing' as const,
+29 -1
View File
@@ -3,7 +3,7 @@
* Claude models, thinking levels, memory backends, and agent profiles * Claude models, thinking levels, memory backends, and agent profiles
*/ */
import type { AgentProfile } from '../types/settings'; import type { AgentProfile, PhaseModelConfig } from '../types/settings';
// ============================================ // ============================================
// Available Models // Available Models
@@ -48,8 +48,36 @@ export const THINKING_LEVELS = [
// Agent Profiles // Agent Profiles
// ============================================ // ============================================
// Default phase model configuration for Auto profile
// Optimized for each phase: fast discovery, quality planning, balanced coding, thorough QA
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
spec: 'sonnet', // Good quality specs without being too slow
planning: 'opus', // Complex architecture decisions benefit from Opus
coding: 'sonnet', // Good balance of speed and quality for implementation
qa: 'sonnet' // Thorough but not overly slow QA
};
// Default phase thinking configuration for Auto profile
export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = {
spec: 'medium', // Moderate thinking for spec creation
planning: 'high', // Deep thinking for planning complex features
coding: 'medium', // Standard thinking for coding
qa: 'high' // Thorough analysis for QA review
};
// Default agent profiles for preset model/thinking configurations // Default agent profiles for preset model/thinking configurations
export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [ export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [
{
id: 'auto',
name: 'Auto (Optimized)',
description: 'Uses different models per phase for optimal speed & quality',
model: 'sonnet', // Fallback/default model
thinkingLevel: 'medium',
icon: 'Sparkles',
isAutoProfile: true,
phaseModels: DEFAULT_PHASE_MODELS,
phaseThinking: DEFAULT_PHASE_THINKING
},
{ {
id: 'complex', id: 'complex',
name: 'Complex Tasks', name: 'Complex Tasks',
+25 -1
View File
@@ -8,14 +8,38 @@ import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './
// Thinking level for Claude model (budget token allocation) // Thinking level for Claude model (budget token allocation)
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Model type shorthand
export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus';
// Phase-based model configuration for Auto profile
// Each phase can use a different model optimized for that task type
export interface PhaseModelConfig {
spec: ModelTypeShort; // Spec creation (discovery, requirements, context)
planning: ModelTypeShort; // Implementation planning
coding: ModelTypeShort; // Actual coding implementation
qa: ModelTypeShort; // QA review and fixing
}
// Thinking level configuration per phase
export interface PhaseThinkingConfig {
spec: ThinkingLevel;
planning: ThinkingLevel;
coding: ThinkingLevel;
qa: ThinkingLevel;
}
// Agent profile for preset model/thinking configurations // Agent profile for preset model/thinking configurations
export interface AgentProfile { export interface AgentProfile {
id: string; id: string;
name: string; name: string;
description: string; description: string;
model: 'haiku' | 'sonnet' | 'opus'; model: ModelTypeShort;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
icon?: string; // Lucide icon name icon?: string; // Lucide icon name
// Auto profile specific - per-phase configuration
isAutoProfile?: boolean;
phaseModels?: PhaseModelConfig;
phaseThinking?: PhaseThinkingConfig;
} }
export interface AppSettings { export interface AppSettings {
+10 -2
View File
@@ -2,7 +2,7 @@
* Task-related types * Task-related types
*/ */
import type { ThinkingLevel } from './settings'; import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'done'; export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'done';
@@ -136,8 +136,12 @@ export interface TaskDraft {
priority: TaskPriority | ''; priority: TaskPriority | '';
complexity: TaskComplexity | ''; complexity: TaskComplexity | '';
impact: TaskImpact | ''; impact: TaskImpact | '';
profileId?: string; // Agent profile ID ('auto', 'complex', 'balanced', 'quick', 'custom')
model: ModelType | ''; model: ModelType | '';
thinkingLevel: ThinkingLevel | ''; thinkingLevel: ThinkingLevel | '';
// Auto profile - per-phase configuration
phaseModels?: PhaseModelConfig;
phaseThinking?: PhaseThinkingConfig;
images: ImageAttachment[]; images: ImageAttachment[];
referencedFiles: ReferencedFile[]; referencedFiles: ReferencedFile[];
requireReviewBeforeCoding?: boolean; requireReviewBeforeCoding?: boolean;
@@ -209,8 +213,12 @@ export interface TaskMetadata {
requireReviewBeforeCoding?: boolean; // Require human review of spec/plan before coding starts requireReviewBeforeCoding?: boolean; // Require human review of spec/plan before coding starts
// Agent configuration (from agent profile or manual selection) // Agent configuration (from agent profile or manual selection)
model?: ModelType; // Claude model to use (haiku, sonnet, opus) model?: ModelType; // Claude model to use (haiku, sonnet, opus) - used when not auto profile
thinkingLevel?: ThinkingLevel; // Thinking budget level (none, low, medium, high, ultrathink) thinkingLevel?: ThinkingLevel; // Thinking budget level (none, low, medium, high, ultrathink)
// Auto profile - per-phase model configuration
isAutoProfile?: boolean; // True when using Auto (Optimized) profile
phaseModels?: PhaseModelConfig; // Per-phase model configuration
phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration
// Archive status // Archive status
archivedAt?: string; // ISO date when task was archived archivedAt?: string; // ISO date when task was archived
+15 -3
View File
@@ -9,7 +9,7 @@ import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from client import create_client from core.client import create_client
from linear_updater import ( from linear_updater import (
LinearTaskState, LinearTaskState,
is_linear_enabled, is_linear_enabled,
@@ -17,6 +17,7 @@ from linear_updater import (
linear_task_started, linear_task_started,
linear_task_stuck, linear_task_stuck,
) )
from phase_config import get_phase_model
from progress import ( from progress import (
count_subtasks, count_subtasks,
count_subtasks_detailed, count_subtasks_detailed,
@@ -244,8 +245,19 @@ async def run_autonomous_agent(
commit_before = get_latest_commit(project_dir) commit_before = get_latest_commit(project_dir)
commit_count_before = get_commit_count(project_dir) commit_count_before = get_commit_count(project_dir)
# Create client (fresh context) # Get the phase-specific model (respects task_metadata.json configuration)
client = create_client(project_dir, spec_dir, model) # first_run means we're in planning phase, otherwise coding phase
current_phase = "planning" if first_run else "coding"
phase_model = get_phase_model(spec_dir, current_phase, model)
# Create client (fresh context) with phase-specific model
# Coding phase uses no extended thinking for fast iteration
client = create_client(
project_dir,
spec_dir,
phase_model,
max_thinking_tokens=None, # No extended thinking for coding
)
# Generate appropriate prompt # Generate appropriate prompt
if first_run: if first_run:
+1 -1
View File
@@ -8,7 +8,7 @@ Handles follow-up planner sessions for adding new subtasks to completed specs.
import logging import logging
from pathlib import Path from pathlib import Path
from client import create_client from core.client import create_client
from task_logger import ( from task_logger import (
LogPhase, LogPhase,
get_task_logger, get_task_logger,
+15 -2
View File
@@ -68,7 +68,7 @@ def handle_build_command(
Args: Args:
project_dir: Project root directory project_dir: Project root directory
spec_dir: Spec directory path spec_dir: Spec directory path
model: Model to use model: Model to use (used as default; may be overridden by task_metadata.json)
max_iterations: Maximum number of iterations (None for unlimited) max_iterations: Maximum number of iterations (None for unlimited)
verbose: Enable verbose output verbose: Enable verbose output
force_isolated: Force isolated workspace mode force_isolated: Force isolated workspace mode
@@ -86,14 +86,27 @@ def handle_build_command(
debug_section, debug_section,
debug_success, debug_success,
) )
from phase_config import get_phase_model
from qa_loop import run_qa_validation_loop, should_run_qa from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment from .utils import print_banner, validate_environment
# Get the resolved model for the planning phase (first phase of build)
# This respects task_metadata.json phase configuration from the UI
planning_model = get_phase_model(spec_dir, "planning", model)
coding_model = get_phase_model(spec_dir, "coding", model)
qa_model = get_phase_model(spec_dir, "qa", model)
print_banner() print_banner()
print(f"\nProject directory: {project_dir}") print(f"\nProject directory: {project_dir}")
print(f"Spec: {spec_dir.name}") print(f"Spec: {spec_dir.name}")
print(f"Model: {model}") # Show phase-specific models if they differ
if planning_model != coding_model or coding_model != qa_model:
print(f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, "
f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, "
f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}")
else:
print(f"Model: {planning_model}")
if max_iterations: if max_iterations:
print(f"Max iterations: {max_iterations}") print(f"Max iterations: {max_iterations}")
+11
View File
@@ -132,6 +132,7 @@ def create_client(
spec_dir: Path, spec_dir: Path,
model: str, model: str,
agent_type: str = "coder", agent_type: str = "coder",
max_thinking_tokens: int | None = None,
) -> ClaudeSDKClient: ) -> ClaudeSDKClient:
""" """
Create a Claude Agent SDK client with multi-layered security. Create a Claude Agent SDK client with multi-layered security.
@@ -142,6 +143,11 @@ def create_client(
model: Claude model to use model: Claude model to use
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer' agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
This determines which custom auto-claude tools are available. This determines which custom auto-claude tools are available.
max_thinking_tokens: Token budget for extended thinking (None = disabled)
- ultrathink: 16000 (spec creation)
- high: 10000 (QA review)
- medium: 5000 (planning, validation)
- None: disabled (coding)
Returns: Returns:
Configured ClaudeSDKClient Configured ClaudeSDKClient
@@ -237,6 +243,10 @@ def create_client(
print(" - Sandbox enabled (OS-level bash isolation)") print(" - Sandbox enabled (OS-level bash isolation)")
print(f" - Filesystem restricted to: {project_dir.resolve()}") print(f" - Filesystem restricted to: {project_dir.resolve()}")
print(" - Bash commands restricted to allowlist") print(" - Bash commands restricted to allowlist")
if max_thinking_tokens:
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
else:
print(" - Extended thinking: disabled")
mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"] mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"]
if linear_enabled: if linear_enabled:
@@ -320,5 +330,6 @@ def create_client(
cwd=str(project_dir.resolve()), cwd=str(project_dir.resolve()),
settings=str(settings_file.resolve()), settings=str(settings_file.resolve()),
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
) )
) )
+290
View File
@@ -0,0 +1,290 @@
"""
Phase Configuration Module
===========================
Handles model and thinking level configuration for different execution phases.
Reads configuration from task_metadata.json and provides resolved model IDs.
"""
import json
from pathlib import Path
from typing import Literal, TypedDict
# Model shorthand to full model ID mapping
MODEL_ID_MAP: dict[str, str] = {
"opus": "claude-opus-4-5-20251101",
"sonnet": "claude-sonnet-4-5-20250929",
"haiku": "claude-haiku-4-5-20251001",
}
# Thinking level to budget tokens mapping (None = no extended thinking)
# Values calibrated for Claude Opus 4.5 extended thinking
THINKING_BUDGET_MAP: dict[str, int | None] = {
"none": None,
"low": 1024,
"medium": 5000, # Balanced thinking for light phases
"high": 10000, # Deep thinking for QA review
"ultrathink": 16000, # Maximum thinking for spec creation
}
# Spec runner phase-specific thinking levels
# Heavy phases use ultrathink for deep analysis
# Light phases use medium after compaction
SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
# Heavy phases - ultrathink (discovery, spec creation, self-critique)
"discovery": "ultrathink",
"spec_writing": "ultrathink",
"self_critique": "ultrathink",
# Light phases - medium (after first invocation with compaction)
"requirements": "medium",
"research": "medium",
"context": "medium",
"planning": "medium",
"validation": "medium",
"quick_spec": "medium",
"historical_context": "medium",
"complexity_assessment": "medium",
}
# Default phase configuration (matches UI defaults)
DEFAULT_PHASE_MODELS: dict[str, str] = {
"spec": "sonnet",
"planning": "opus",
"coding": "sonnet",
"qa": "sonnet",
}
DEFAULT_PHASE_THINKING: dict[str, str] = {
"spec": "medium",
"planning": "high",
"coding": "medium",
"qa": "high",
}
class PhaseModelConfig(TypedDict, total=False):
spec: str
planning: str
coding: str
qa: str
class PhaseThinkingConfig(TypedDict, total=False):
spec: str
planning: str
coding: str
qa: str
class TaskMetadataConfig(TypedDict, total=False):
"""Structure of model-related fields in task_metadata.json"""
isAutoProfile: bool
phaseModels: PhaseModelConfig
phaseThinking: PhaseThinkingConfig
model: str
thinkingLevel: str
Phase = Literal["spec", "planning", "coding", "qa"]
def resolve_model_id(model: str) -> str:
"""
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
If the model is already a full ID, return it unchanged.
Args:
model: Model shorthand or full ID
Returns:
Full Claude model ID
"""
# Check if it's a shorthand
if model in MODEL_ID_MAP:
return MODEL_ID_MAP[model]
# Already a full model ID
return model
def get_thinking_budget(thinking_level: str) -> int | None:
"""
Get the thinking budget for a thinking level.
Args:
thinking_level: Thinking level (none, low, medium, high, ultrathink)
Returns:
Token budget or None for no extended thinking
"""
return THINKING_BUDGET_MAP.get(thinking_level, THINKING_BUDGET_MAP["medium"])
def load_task_metadata(spec_dir: Path) -> TaskMetadataConfig | None:
"""
Load task_metadata.json from the spec directory.
Args:
spec_dir: Path to the spec directory
Returns:
Parsed task metadata or None if not found
"""
metadata_path = spec_dir / "task_metadata.json"
if not metadata_path.exists():
return None
try:
with open(metadata_path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def get_phase_model(
spec_dir: Path,
phase: Phase,
cli_model: str | None = None,
) -> str:
"""
Get the resolved model ID for a specific execution phase.
Priority:
1. CLI argument (if provided)
2. Phase-specific config from task_metadata.json (if auto profile)
3. Single model from task_metadata.json (if not auto profile)
4. Default phase configuration
Args:
spec_dir: Path to the spec directory
phase: Execution phase (spec, planning, coding, qa)
cli_model: Model from CLI argument (optional)
Returns:
Resolved full model ID
"""
# CLI argument takes precedence
if cli_model:
return resolve_model_id(cli_model)
# Load task metadata
metadata = load_task_metadata(spec_dir)
if metadata:
# Check for auto profile with phase-specific config
if metadata.get("isAutoProfile") and metadata.get("phaseModels"):
phase_models = metadata["phaseModels"]
model = phase_models.get(phase, DEFAULT_PHASE_MODELS[phase])
return resolve_model_id(model)
# Non-auto profile: use single model
if metadata.get("model"):
return resolve_model_id(metadata["model"])
# Fall back to default phase configuration
return resolve_model_id(DEFAULT_PHASE_MODELS[phase])
def get_phase_thinking(
spec_dir: Path,
phase: Phase,
cli_thinking: str | None = None,
) -> str:
"""
Get the thinking level for a specific execution phase.
Priority:
1. CLI argument (if provided)
2. Phase-specific config from task_metadata.json (if auto profile)
3. Single thinking level from task_metadata.json (if not auto profile)
4. Default phase configuration
Args:
spec_dir: Path to the spec directory
phase: Execution phase (spec, planning, coding, qa)
cli_thinking: Thinking level from CLI argument (optional)
Returns:
Thinking level string
"""
# CLI argument takes precedence
if cli_thinking:
return cli_thinking
# Load task metadata
metadata = load_task_metadata(spec_dir)
if metadata:
# Check for auto profile with phase-specific config
if metadata.get("isAutoProfile") and metadata.get("phaseThinking"):
phase_thinking = metadata["phaseThinking"]
return phase_thinking.get(phase, DEFAULT_PHASE_THINKING[phase])
# Non-auto profile: use single thinking level
if metadata.get("thinkingLevel"):
return metadata["thinkingLevel"]
# Fall back to default phase configuration
return DEFAULT_PHASE_THINKING[phase]
def get_phase_thinking_budget(
spec_dir: Path,
phase: Phase,
cli_thinking: str | None = None,
) -> int | None:
"""
Get the thinking budget tokens for a specific execution phase.
Args:
spec_dir: Path to the spec directory
phase: Execution phase (spec, planning, coding, qa)
cli_thinking: Thinking level from CLI argument (optional)
Returns:
Token budget or None for no extended thinking
"""
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
return get_thinking_budget(thinking_level)
def get_phase_config(
spec_dir: Path,
phase: Phase,
cli_model: str | None = None,
cli_thinking: str | None = None,
) -> tuple[str, str, int | None]:
"""
Get the full configuration for a specific execution phase.
Args:
spec_dir: Path to the spec directory
phase: Execution phase (spec, planning, coding, qa)
cli_model: Model from CLI argument (optional)
cli_thinking: Thinking level from CLI argument (optional)
Returns:
Tuple of (model_id, thinking_level, thinking_budget)
"""
model_id = get_phase_model(spec_dir, phase, cli_model)
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
thinking_budget = get_thinking_budget(thinking_level)
return model_id, thinking_level, thinking_budget
def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
"""
Get the thinking budget for a specific spec runner phase.
This maps granular spec phases (discovery, spec_writing, etc.) to their
appropriate thinking budgets based on SPEC_PHASE_THINKING_LEVELS.
Args:
phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing')
Returns:
Token budget for extended thinking, or None for no extended thinking
"""
thinking_level = SPEC_PHASE_THINKING_LEVELS.get(phase_name, "medium")
return get_thinking_budget(thinking_level)
+33 -7
View File
@@ -9,8 +9,9 @@ approval or max iterations.
import time as time_module import time as time_module
from pathlib import Path from pathlib import Path
from client import create_client from core.client import create_client
from debug import debug, debug_error, debug_section, debug_success, debug_warning from debug import debug, debug_error, debug_section, debug_success, debug_warning
from phase_config import get_phase_model, get_thinking_budget
from linear_updater import ( from linear_updater import (
LinearTaskState, LinearTaskState,
is_linear_enabled, is_linear_enabled,
@@ -151,9 +152,22 @@ async def run_qa_validation_loop(
print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---") print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---")
# Run QA reviewer # Run QA reviewer with phase-specific model and high thinking budget
debug("qa_loop", "Creating client for QA reviewer session...") qa_model = get_phase_model(spec_dir, "qa", model)
client = create_client(project_dir, spec_dir, model) qa_thinking_budget = get_thinking_budget("high") # 10,000 tokens for thorough review
debug(
"qa_loop",
"Creating client for QA reviewer session...",
model=qa_model,
thinking_budget=qa_thinking_budget,
)
client = create_client(
project_dir,
spec_dir,
qa_model,
agent_type="qa_reviewer",
max_thinking_tokens=qa_thinking_budget,
)
async with client: async with client:
debug("qa_loop", "Running QA reviewer agent session...") debug("qa_loop", "Running QA reviewer agent session...")
@@ -278,11 +292,23 @@ async def run_qa_validation_loop(
print("Escalating to human review.") print("Escalating to human review.")
break break
# Run fixer # Run fixer with medium thinking budget
debug("qa_loop", "Starting QA fixer session...") fixer_thinking_budget = get_thinking_budget("medium") # 5,000 tokens for focused fixes
debug(
"qa_loop",
"Starting QA fixer session...",
model=qa_model,
thinking_budget=fixer_thinking_budget,
)
print("\nRunning QA Fixer Agent...") print("\nRunning QA Fixer Agent...")
fix_client = create_client(project_dir, spec_dir, model) fix_client = create_client(
project_dir,
spec_dir,
qa_model,
agent_type="qa_fixer",
max_thinking_tokens=fixer_thinking_budget,
)
async with fix_client: async with fix_client:
fix_status, fix_response = await run_qa_fixer_session( fix_status, fix_response = await run_qa_fixer_session(
+20 -7
View File
@@ -92,6 +92,7 @@ elif dev_env_file.exists():
load_dotenv(dev_env_file) load_dotenv(dev_env_file)
from debug import debug, debug_error, debug_section, debug_success from debug import debug, debug_error, debug_section, debug_success
from phase_config import get_phase_config, resolve_model_id
from review import ReviewState from review import ReviewState
from spec import SpecOrchestrator from spec import SpecOrchestrator
from ui import Icons, highlight, icon, muted, print_section, print_status from ui import Icons, highlight, icon, muted, print_section, print_status
@@ -161,8 +162,15 @@ Examples:
parser.add_argument( parser.add_argument(
"--model", "--model",
type=str, type=str,
default="claude-opus-4-5-20251101", default="sonnet",
help="Model to use for agent phases", help="Model to use for agent phases (haiku, sonnet, opus, or full model ID)",
)
parser.add_argument(
"--thinking-level",
type=str,
default="medium",
choices=["none", "low", "medium", "high", "ultrathink"],
help="Thinking level for extended thinking (none, low, medium, high, ultrathink)",
) )
parser.add_argument( parser.add_argument(
"--no-ai-assessment", "--no-ai-assessment",
@@ -234,12 +242,16 @@ Examples:
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now go to .auto-claude/specs/\n" f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now go to .auto-claude/specs/\n"
) )
# Resolve model shorthand to full model ID
resolved_model = resolve_model_id(args.model)
debug( debug(
"spec_runner", "spec_runner",
"Creating spec orchestrator", "Creating spec orchestrator",
project_dir=str(project_dir), project_dir=str(project_dir),
task_description=task_description[:200] if task_description else None, task_description=task_description[:200] if task_description else None,
model=args.model, model=resolved_model,
thinking_level=args.thinking_level,
complexity_override=args.complexity, complexity_override=args.complexity,
use_ai_assessment=not args.no_ai_assessment, use_ai_assessment=not args.no_ai_assessment,
interactive=args.interactive or not task_description, interactive=args.interactive or not task_description,
@@ -251,7 +263,8 @@ Examples:
task_description=task_description, task_description=task_description,
spec_name=args.continue_spec, spec_name=args.continue_spec,
spec_dir=args.spec_dir, spec_dir=args.spec_dir,
model=args.model, model=resolved_model,
thinking_level=args.thinking_level,
complexity_override=args.complexity, complexity_override=args.complexity,
use_ai_assessment=not args.no_ai_assessment, use_ai_assessment=not args.no_ai_assessment,
dev_mode=args.dev, dev_mode=args.dev,
@@ -321,9 +334,9 @@ Examples:
if args.dev: if args.dev:
run_cmd.append("--dev") run_cmd.append("--dev")
# Pass through model if not default # Note: Model configuration for subsequent phases (planning, coding, qa)
if args.model != "claude-opus-4-5-20251101": # is read from task_metadata.json by run.py, so we don't pass it here.
run_cmd.extend(["--model", args.model]) # This allows per-phase configuration when using Auto profile.
debug( debug(
"spec_runner", "spec_runner",
+155
View File
@@ -0,0 +1,155 @@
"""
Conversation Compaction Module
==============================
Summarizes phase outputs to maintain continuity between phases while
reducing token usage. After each phase completes, key findings are
summarized and passed as context to subsequent phases.
"""
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from core.auth import get_sdk_env_vars, require_auth_token
async def summarize_phase_output(
phase_name: str,
phase_output: str,
model: str = "claude-sonnet-4-5-20250929",
target_words: int = 500,
) -> str:
"""
Summarize phase output to a concise summary for subsequent phases.
Uses Sonnet for cost efficiency since this is a simple summarization task.
Args:
phase_name: Name of the completed phase (e.g., 'discovery', 'requirements')
phase_output: Full output content from the phase (file contents, decisions)
model: Model to use for summarization (defaults to Sonnet for efficiency)
target_words: Target summary length in words (~500-1000 recommended)
Returns:
Concise summary of key findings, decisions, and insights from the phase
"""
# Validate auth token
require_auth_token()
# Limit input size to avoid token overflow
max_input_chars = 15000
truncated_output = phase_output[:max_input_chars]
if len(phase_output) > max_input_chars:
truncated_output += "\n\n[... output truncated for summarization ...]"
prompt = f"""Summarize the key findings from the "{phase_name}" phase in {target_words} words or less.
Focus on extracting ONLY the most critical information that subsequent phases need:
- Key decisions made and their rationale
- Critical files, components, or patterns identified
- Important constraints or requirements discovered
- Actionable insights for implementation
Be concise and use bullet points. Skip boilerplate and meta-commentary.
## Phase Output:
{truncated_output}
## Summary:
"""
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=model,
system_prompt=(
"You are a concise technical summarizer. Extract only the most "
"critical information from phase outputs. Use bullet points. "
"Focus on decisions, discoveries, and actionable insights."
),
allowed_tools=[], # No tools needed for summarization
max_turns=1,
env=get_sdk_env_vars(),
)
)
try:
async with client:
await client.query(prompt)
response_text = ""
async for msg in client.receive_response():
if hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
response_text += block.text
return response_text.strip()
except Exception as e:
# Fallback: return truncated raw output on error
# This ensures we don't block the pipeline if summarization fails
fallback = phase_output[:2000]
if len(phase_output) > 2000:
fallback += "\n\n[... truncated ...]"
return f"[Summarization failed: {e}]\n\n{fallback}"
def format_phase_summaries(summaries: dict[str, str]) -> str:
"""
Format accumulated phase summaries for injection into agent context.
Args:
summaries: Dict mapping phase names to their summaries
Returns:
Formatted string suitable for agent context injection
"""
if not summaries:
return ""
formatted_parts = ["## Context from Previous Phases\n"]
for phase_name, summary in summaries.items():
formatted_parts.append(f"### {phase_name.replace('_', ' ').title()}\n{summary}\n")
return "\n".join(formatted_parts)
async def gather_phase_outputs(spec_dir: Path, phase_name: str) -> str:
"""
Gather output files from a completed phase for summarization.
Args:
spec_dir: Path to the spec directory
phase_name: Name of the completed phase
Returns:
Concatenated content of phase output files
"""
outputs = []
# Map phases to their expected output files
phase_outputs: dict[str, list[str]] = {
"discovery": ["context.json"],
"requirements": ["requirements.json"],
"research": ["research.json"],
"context": ["context.json"],
"quick_spec": ["spec.md"],
"spec_writing": ["spec.md"],
"self_critique": ["spec.md", "critique_notes.md"],
"planning": ["implementation_plan.json"],
"validation": [], # No output files to summarize
}
output_files = phase_outputs.get(phase_name, [])
for filename in output_files:
file_path = spec_dir / filename
if file_path.exists():
try:
content = file_path.read_text()
# Limit individual file size
if len(content) > 10000:
content = content[:10000] + "\n\n[... file truncated ...]"
outputs.append(f"**{filename}**:\n```\n{content}\n```")
except Exception:
pass # Skip files that can't be read
return "\n\n".join(outputs) if outputs else ""
+5 -1
View File
@@ -73,7 +73,10 @@ class PlanningPhaseMixin:
f"Running planner agent (attempt {attempt + 1})...", "progress" f"Running planner agent (attempt {attempt + 1})...", "progress"
) )
success, output = await self.run_agent_fn("planner.md") success, output = await self.run_agent_fn(
"planner.md",
phase_name="planning",
)
if success and plan_file.exists(): if success and plan_file.exists():
result = self.spec_validator.validate_implementation_plan() result = self.spec_validator.validate_implementation_plan()
@@ -161,6 +164,7 @@ Read the failed files, understand the errors, and fix them.
success, output = await self.run_agent_fn( success, output = await self.run_agent_fn(
"validation_fixer.md", "validation_fixer.md",
additional_context=context_str, additional_context=context_str,
phase_name="validation",
) )
if not success: if not success:
@@ -221,6 +221,7 @@ Output your findings to research.json.
success, output = await self.run_agent_fn( success, output = await self.run_agent_fn(
"spec_researcher.md", "spec_researcher.md",
additional_context=context_str, additional_context=context_str,
phase_name="research",
) )
if success and research_file.exists(): if success and research_file.exists():
+6 -1
View File
@@ -50,6 +50,7 @@ Create:
success, output = await self.run_agent_fn( success, output = await self.run_agent_fn(
"spec_quick.md", "spec_quick.md",
additional_context=context_str, additional_context=context_str,
phase_name="quick_spec",
) )
if success and spec_file.exists(): if success and spec_file.exists():
@@ -85,7 +86,10 @@ Create:
f"Running spec writer (attempt {attempt + 1})...", "progress" f"Running spec writer (attempt {attempt + 1})...", "progress"
) )
success, output = await self.run_agent_fn("spec_writer.md") success, output = await self.run_agent_fn(
"spec_writer.md",
phase_name="spec_writing",
)
if success and spec_file.exists(): if success and spec_file.exists():
result = self.spec_validator.validate_spec_document() result = self.spec_validator.validate_spec_document()
@@ -162,6 +166,7 @@ Output critique_report.json with:
success, output = await self.run_agent_fn( success, output = await self.run_agent_fn(
"spec_critic.md", "spec_critic.md",
additional_context=context_str, additional_context=context_str,
phase_name="self_critique",
) )
if success: if success:
+26 -4
View File
@@ -12,7 +12,7 @@ from ui.capabilities import configure_safe_encoding
configure_safe_encoding() configure_safe_encoding()
from client import create_client from core.client import create_client
from debug import debug, debug_detailed, debug_error, debug_section, debug_success from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from task_logger import ( from task_logger import (
LogEntryType, LogEntryType,
@@ -49,6 +49,8 @@ class AgentRunner:
prompt_file: str, prompt_file: str,
additional_context: str = "", additional_context: str = "",
interactive: bool = False, interactive: bool = False,
thinking_budget: int | None = None,
prior_phase_summaries: str | None = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Run an agent with the given prompt. """Run an agent with the given prompt.
@@ -56,6 +58,8 @@ class AgentRunner:
prompt_file: The prompt file to use (relative to prompts directory) prompt_file: The prompt file to use (relative to prompts directory)
additional_context: Additional context to add to the prompt additional_context: Additional context to add to the prompt
interactive: Whether to run in interactive mode interactive: Whether to run in interactive mode
thinking_budget: Token budget for extended thinking (None = disabled)
prior_phase_summaries: Summaries from previous phases for context
Returns: Returns:
Tuple of (success, response_text) Tuple of (success, response_text)
@@ -88,6 +92,15 @@ class AgentRunner:
prompt += f"\n\n---\n\n**Spec Directory**: {self.spec_dir}\n" prompt += f"\n\n---\n\n**Spec Directory**: {self.spec_dir}\n"
prompt += f"**Project Directory**: {self.project_dir}\n" prompt += f"**Project Directory**: {self.project_dir}\n"
# Add summaries from previous phases (compaction)
if prior_phase_summaries:
prompt += f"\n{prior_phase_summaries}\n"
debug_detailed(
"agent_runner",
"Added prior phase summaries",
summaries_length=len(prior_phase_summaries),
)
if additional_context: if additional_context:
prompt += f"\n{additional_context}\n" prompt += f"\n{additional_context}\n"
debug_detailed( debug_detailed(
@@ -96,9 +109,18 @@ class AgentRunner:
context_length=len(additional_context), context_length=len(additional_context),
) )
# Create client # Create client with thinking budget
debug("agent_runner", "Creating Claude SDK client...") debug(
client = create_client(self.project_dir, self.spec_dir, self.model) "agent_runner",
"Creating Claude SDK client...",
thinking_budget=thinking_budget,
)
client = create_client(
self.project_dir,
self.spec_dir,
self.model,
max_thinking_tokens=thinking_budget,
)
current_tool = None current_tool = None
message_count = 0 message_count = 0
+63 -2
View File
@@ -9,6 +9,7 @@ import json
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from phase_config import get_spec_phase_thinking_budget
from review import run_review_checkpoint from review import run_review_checkpoint
from task_logger import ( from task_logger import (
LogEntryType, LogEntryType,
@@ -27,6 +28,7 @@ from ui import (
) )
from .. import complexity, phases, requirements from .. import complexity, phases, requirements
from ..compaction import format_phase_summaries, gather_phase_outputs, summarize_phase_output
from ..validate_pkg.spec_validator import SpecValidator from ..validate_pkg.spec_validator import SpecValidator
from .agent_runner import AgentRunner from .agent_runner import AgentRunner
from .models import ( from .models import (
@@ -48,7 +50,8 @@ class SpecOrchestrator:
spec_name: str | None = None, spec_name: str | None = None,
spec_dir: Path spec_dir: Path
| None = None, # Use existing spec directory (for UI integration) | None = None, # Use existing spec directory (for UI integration)
model: str = "claude-opus-4-5-20251101", model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium", # Thinking level for extended thinking
complexity_override: str | None = None, # Force a specific complexity complexity_override: str | None = None, # Force a specific complexity
use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics) use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics)
dev_mode: bool = False, # Dev mode: specs in gitignored folder, code changes to auto-claude/ dev_mode: bool = False, # Dev mode: specs in gitignored folder, code changes to auto-claude/
@@ -61,6 +64,7 @@ class SpecOrchestrator:
spec_name: Optional spec name (for existing specs) spec_name: Optional spec name (for existing specs)
spec_dir: Optional existing spec directory (for UI integration) spec_dir: Optional existing spec directory (for UI integration)
model: The model to use for agent execution model: The model to use for agent execution
thinking_level: Thinking level (none, low, medium, high, ultrathink)
complexity_override: Force a specific complexity level complexity_override: Force a specific complexity level
use_ai_assessment: Whether to use AI for complexity assessment use_ai_assessment: Whether to use AI for complexity assessment
dev_mode: Deprecated, kept for API compatibility dev_mode: Deprecated, kept for API compatibility
@@ -68,6 +72,7 @@ class SpecOrchestrator:
self.project_dir = Path(project_dir) self.project_dir = Path(project_dir)
self.task_description = task_description self.task_description = task_description
self.model = model self.model = model
self.thinking_level = thinking_level
self.complexity_override = complexity_override self.complexity_override = complexity_override
self.use_ai_assessment = use_ai_assessment self.use_ai_assessment = use_ai_assessment
self.dev_mode = dev_mode self.dev_mode = dev_mode
@@ -96,6 +101,10 @@ class SpecOrchestrator:
# Agent runner (initialized when needed) # Agent runner (initialized when needed)
self._agent_runner: AgentRunner | None = None self._agent_runner: AgentRunner | None = None
# Phase summaries for conversation compaction
# Stores summaries from completed phases to provide context to subsequent phases
self._phase_summaries: dict[str, str] = {}
def _get_agent_runner(self) -> AgentRunner: def _get_agent_runner(self) -> AgentRunner:
"""Get or create the agent runner. """Get or create the agent runner.
@@ -114,6 +123,7 @@ class SpecOrchestrator:
prompt_file: str, prompt_file: str,
additional_context: str = "", additional_context: str = "",
interactive: bool = False, interactive: bool = False,
phase_name: str | None = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Run an agent with the given prompt. """Run an agent with the given prompt.
@@ -121,12 +131,55 @@ class SpecOrchestrator:
prompt_file: The prompt file to use prompt_file: The prompt file to use
additional_context: Additional context to add additional_context: Additional context to add
interactive: Whether to run in interactive mode interactive: Whether to run in interactive mode
phase_name: Name of the phase (for thinking budget lookup)
Returns: Returns:
Tuple of (success, response_text) Tuple of (success, response_text)
""" """
runner = self._get_agent_runner() runner = self._get_agent_runner()
return await runner.run_agent(prompt_file, additional_context, interactive)
# Get thinking budget for this phase
thinking_budget = None
if phase_name:
thinking_budget = get_spec_phase_thinking_budget(phase_name)
# Format prior phase summaries for context
prior_summaries = format_phase_summaries(self._phase_summaries)
return await runner.run_agent(
prompt_file,
additional_context,
interactive,
thinking_budget=thinking_budget,
prior_phase_summaries=prior_summaries if prior_summaries else None,
)
async def _store_phase_summary(self, phase_name: str) -> None:
"""Summarize and store phase output for subsequent phases.
Args:
phase_name: Name of the completed phase
"""
try:
# Gather outputs from this phase
phase_output = await gather_phase_outputs(self.spec_dir, phase_name)
if not phase_output:
return
# Summarize the output
summary = await summarize_phase_output(
phase_name,
phase_output,
model="claude-sonnet-4-5-20250929", # Use Sonnet for efficiency
target_words=500,
)
if summary:
self._phase_summaries[phase_name] = summary
except Exception as e:
# Don't fail the pipeline if summarization fails
print_status(f"Phase summarization skipped: {e}", "warning")
async def run(self, interactive: bool = True, auto_approve: bool = False) -> bool: async def run(self, interactive: bool = True, auto_approve: bool = False) -> bool:
"""Run the spec creation process with dynamic phase selection. """Run the spec creation process with dynamic phase selection.
@@ -199,6 +252,8 @@ class SpecOrchestrator:
LogPhase.PLANNING, success=False, message="Discovery failed" LogPhase.PLANNING, success=False, message="Discovery failed"
) )
return False return False
# Store summary for subsequent phases (compaction)
await self._store_phase_summary("discovery")
# === PHASE 2: REQUIREMENTS GATHERING === # === PHASE 2: REQUIREMENTS GATHERING ===
result = await run_phase( result = await run_phase(
@@ -213,6 +268,8 @@ class SpecOrchestrator:
message="Requirements gathering failed", message="Requirements gathering failed",
) )
return False return False
# Store summary for subsequent phases (compaction)
await self._store_phase_summary("requirements")
# Rename spec folder with better name from requirements # Rename spec folder with better name from requirements
rename_spec_dir_from_requirements(self.spec_dir) rename_spec_dir_from_requirements(self.spec_dir)
@@ -275,6 +332,10 @@ class SpecOrchestrator:
results.append(result) results.append(result)
phases_executed.append(phase_name) phases_executed.append(phase_name)
# Store summary for subsequent phases (compaction)
if result.success:
await self._store_phase_summary(phase_name)
if not result.success: if not result.success:
print() print()
print_status( print_status(
+92 -3
View File
@@ -5,6 +5,8 @@ Main TaskLogger class for logging task execution.
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from core.debug import debug, debug_error, debug_info, debug_success, is_debug_enabled
from .models import LogEntry, LogEntryType, LogPhase from .models import LogEntry, LogEntryType, LogPhase
from .storage import LogStorage from .storage import LogStorage
from .streaming import emit_marker from .streaming import emit_marker
@@ -62,6 +64,49 @@ class TaskLogger:
"""Add an entry to the current phase.""" """Add an entry to the current phase."""
self.storage.add_entry(entry) self.storage.add_entry(entry)
def _debug_log(
self,
content: str,
entry_type: LogEntryType = LogEntryType.TEXT,
phase: str | None = None,
tool_name: str | None = None,
**kwargs,
) -> None:
"""
Output a log entry to the terminal via the debug logging system.
Only outputs when DEBUG=true is set in the environment.
Args:
content: The message content
entry_type: Type of entry for formatting
phase: Current phase name
tool_name: Tool name if this is a tool log
**kwargs: Additional key-value pairs for debug output
"""
if not is_debug_enabled():
return
module = "task_logger"
prefix = f"[{phase or 'unknown'}]" if phase else ""
if tool_name:
prefix = f"{prefix}[{tool_name}]"
message = f"{prefix} {content}" if prefix else content
# Route to appropriate debug function based on entry type
if entry_type == LogEntryType.ERROR:
debug_error(module, message, **kwargs)
elif entry_type == LogEntryType.SUCCESS:
debug_success(module, message, **kwargs)
elif entry_type in (LogEntryType.INFO, LogEntryType.PHASE_START, LogEntryType.PHASE_END):
debug_info(module, message, **kwargs)
elif entry_type in (LogEntryType.TOOL_START, LogEntryType.TOOL_END):
debug(module, message, level=2, **kwargs)
else:
debug(module, message, **kwargs)
def set_session(self, session: int) -> None: def set_session(self, session: int) -> None:
"""Set the current session number.""" """Set the current session number."""
self.current_session = session self.current_session = session
@@ -110,15 +155,19 @@ class TaskLogger:
self._emit("PHASE_START", {"phase": phase_key, "timestamp": self._timestamp()}) self._emit("PHASE_START", {"phase": phase_key, "timestamp": self._timestamp()})
# Add phase start entry # Add phase start entry
phase_message = message or f"Starting {phase_key} phase"
entry = LogEntry( entry = LogEntry(
timestamp=self._timestamp(), timestamp=self._timestamp(),
type=LogEntryType.PHASE_START.value, type=LogEntryType.PHASE_START.value,
content=message or f"Starting {phase_key} phase", content=phase_message,
phase=phase_key, phase=phase_key,
session=self.current_session, session=self.current_session,
) )
self._add_entry(entry) self._add_entry(entry)
# Debug log (when DEBUG=true)
self._debug_log(phase_message, LogEntryType.PHASE_START, phase_key)
# Also print the message # Also print the message
if message: if message:
print(message, flush=True) print(message, flush=True)
@@ -147,16 +196,20 @@ class TaskLogger:
) )
# Add phase end entry # Add phase end entry
phase_message = message or f"{'Completed' if success else 'Failed'} {phase_key} phase"
entry = LogEntry( entry = LogEntry(
timestamp=self._timestamp(), timestamp=self._timestamp(),
type=LogEntryType.PHASE_END.value, type=LogEntryType.PHASE_END.value,
content=message content=phase_message,
or f"{'Completed' if success else 'Failed'} {phase_key} phase",
phase=phase_key, phase=phase_key,
session=self.current_session, session=self.current_session,
) )
self._add_entry(entry) self._add_entry(entry)
# Debug log (when DEBUG=true)
entry_type = LogEntryType.SUCCESS if success else LogEntryType.ERROR
self._debug_log(phase_message, entry_type, phase_key)
if message: if message:
print(message, flush=True) print(message, flush=True)
@@ -205,6 +258,9 @@ class TaskLogger:
}, },
) )
# Debug log (when DEBUG=true)
self._debug_log(content, entry_type, phase_key, subtask=self.current_subtask)
# Also print to console (unless caller handles printing) # Also print to console (unless caller handles printing)
if print_to_console: if print_to_console:
print(content, flush=True) print(content, flush=True)
@@ -272,6 +328,16 @@ class TaskLogger:
}, },
) )
# Debug log (when DEBUG=true) - include detail for verbose mode
self._debug_log(
content,
entry_type,
phase_key,
subtask=self.current_subtask,
subphase=subphase,
detail=detail[:500] + "..." if len(detail) > 500 else detail,
)
if print_to_console: if print_to_console:
print(content, flush=True) print(content, flush=True)
@@ -308,6 +374,9 @@ class TaskLogger:
{"subphase": subphase, "phase": phase_key, "timestamp": self._timestamp()}, {"subphase": subphase, "phase": phase_key, "timestamp": self._timestamp()},
) )
# Debug log (when DEBUG=true)
self._debug_log(f"Starting {subphase}", LogEntryType.INFO, phase_key, subphase=subphase)
if print_to_console: if print_to_console:
print(f"\n--- {subphase} ---", flush=True) print(f"\n--- {subphase} ---", flush=True)
@@ -352,6 +421,14 @@ class TaskLogger:
{"name": tool_name, "input": display_input, "phase": phase_key}, {"name": tool_name, "input": display_input, "phase": phase_key},
) )
# Debug log (when DEBUG=true)
self._debug_log(
display_input or "started",
LogEntryType.TOOL_START,
phase_key,
tool_name=tool_name,
)
if print_to_console: if print_to_console:
print(f"\n[Tool: {tool_name}]", flush=True) print(f"\n[Tool: {tool_name}]", flush=True)
@@ -419,6 +496,18 @@ class TaskLogger:
}, },
) )
# Debug log (when DEBUG=true)
debug_kwargs = {"status": status}
if display_result:
debug_kwargs["result"] = display_result
self._debug_log(
content,
LogEntryType.SUCCESS if success else LogEntryType.ERROR,
phase_key,
tool_name=tool_name,
**debug_kwargs,
)
if print_to_console: if print_to_console:
if result: if result:
print(f" [{status}] {display_result}", flush=True) print(f" [{status}] {display_result}", flush=True)
View File