Improvement to let claude code decide parallell agents
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
# Subtask Worker Agent
|
||||
|
||||
You are a focused implementation agent working on a single subtask within a larger feature build. Your job is to implement ONLY your assigned subtask, commit your changes, and report completion.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
1. **Read** the subtask specification carefully
|
||||
2. **Implement** ONLY the assigned subtask - nothing more, nothing less
|
||||
3. **Verify** your implementation works (tests pass, build succeeds)
|
||||
4. **Commit** your changes with a clear commit message
|
||||
5. **Update** the implementation plan to mark the subtask as completed
|
||||
|
||||
## Important Constraints
|
||||
|
||||
### Stay Focused
|
||||
- You are part of a **parallel build** - other agents may be working on other subtasks
|
||||
- **DO NOT** modify files outside your subtask's scope
|
||||
- **DO NOT** implement other subtasks, even if they seem related
|
||||
- **DO NOT** refactor code unless explicitly required by your subtask
|
||||
|
||||
### File Boundaries
|
||||
- Your subtask specifies which files to modify/create
|
||||
- Stick to those files unless absolutely necessary
|
||||
- If you need to touch other files, they should be minimal changes (imports, exports)
|
||||
|
||||
### Commit Strategy
|
||||
- Make atomic commits for your subtask
|
||||
- Use clear commit messages: `feat(subtask-id): description`
|
||||
- Commit after verification passes
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. Read subtask specification
|
||||
└─> Understand what needs to be implemented
|
||||
|
||||
2. Implement the subtask
|
||||
└─> Write code following existing patterns
|
||||
└─> Keep changes minimal and focused
|
||||
|
||||
3. Verify the implementation
|
||||
└─> Run relevant tests
|
||||
└─> Check for lint errors
|
||||
└─> Ensure build succeeds
|
||||
|
||||
4. Commit changes
|
||||
└─> git add <your files>
|
||||
└─> git commit -m "feat(subtask-id): description"
|
||||
|
||||
5. Update implementation plan
|
||||
└─> Mark subtask status as "completed"
|
||||
└─> Save the updated plan
|
||||
```
|
||||
|
||||
## Communication
|
||||
|
||||
- Report progress through the implementation plan JSON
|
||||
- If you encounter blockers, update the subtask status to indicate the issue
|
||||
- Keep your response focused - avoid lengthy explanations
|
||||
|
||||
## Your Assigned Subtask
|
||||
|
||||
[The orchestrator will inject the specific subtask details here at runtime]
|
||||
@@ -33,9 +33,6 @@ python auto-claude/spec_runner.py --task "Fix button" --complexity simple
|
||||
# Run autonomous build
|
||||
python auto-claude/run.py --spec 001
|
||||
|
||||
# Run with parallel workers
|
||||
python auto-claude/run.py --spec 001 --parallel 2
|
||||
|
||||
# List all specs
|
||||
python auto-claude/run.py --list
|
||||
```
|
||||
@@ -92,7 +89,7 @@ python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --c
|
||||
|
||||
**Implementation (run.py → agent.py)** - Multi-session build:
|
||||
1. Planner Agent creates subtask-based implementation plan
|
||||
2. Coder Agent implements subtasks (sequential or parallel via Task tool)
|
||||
2. Coder Agent implements subtasks (can spawn subagents for parallel work)
|
||||
3. QA Reviewer validates acceptance criteria
|
||||
4. QA Fixer resolves issues in a loop
|
||||
|
||||
@@ -101,7 +98,6 @@ python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --c
|
||||
- **client.py** - Claude SDK client with security hooks and tool permissions
|
||||
- **security.py** + **project_analyzer.py** - Dynamic command allowlisting based on detected project stack
|
||||
- **worktree.py** - Git worktree isolation for safe feature development
|
||||
- **task_tool.py** - Parallel subtask execution using Claude Code's Task tool
|
||||
- **memory.py** - File-based session memory (primary, always-available storage)
|
||||
- **graphiti_memory.py** - Optional graph-based cross-session memory with semantic search
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama)
|
||||
@@ -144,14 +140,14 @@ main (user's branch)
|
||||
|
||||
**Key principles:**
|
||||
- ONE branch per spec (`auto-claude/{spec-name}`)
|
||||
- Parallel execution uses Claude Code's Task tool (not separate branches)
|
||||
- Parallel work uses subagents (agent decides when to spawn)
|
||||
- NO automatic pushes to GitHub - user controls when to push
|
||||
- User reviews in spec worktree (`.worktrees/{spec-name}/`)
|
||||
- Final merge: spec branch → main (after user approval)
|
||||
|
||||
**Workflow:**
|
||||
1. Build runs in isolated worktree on spec branch
|
||||
2. Parallel subtasks execute via Task tool (same branch)
|
||||
2. Agent implements subtasks (can spawn subagents for parallel work)
|
||||
3. User tests feature in `.worktrees/{spec-name}/`
|
||||
4. User runs `--merge` to add to their project
|
||||
5. User pushes to remote when ready
|
||||
|
||||
@@ -2991,27 +2991,58 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n'
|
||||
.slice(0, 10); // Last 10 specs
|
||||
|
||||
for (const specDir of recentSpecDirs) {
|
||||
// Look for session memory files
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
// Load session insights from session_insights subdirectory
|
||||
const sessionInsightsDir = path.join(memoryDir, 'session_insights');
|
||||
if (existsSync(sessionInsightsDir)) {
|
||||
const sessionFiles = readdirSync(sessionInsightsDir)
|
||||
.filter((f: string) => f.startsWith('session_') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const memFile of memoryFiles.slice(0, 3)) {
|
||||
for (const sessionFile of sessionFiles.slice(0, 3)) {
|
||||
try {
|
||||
const sessionPath = path.join(sessionInsightsDir, sessionFile);
|
||||
const sessionContent = readFileSync(sessionPath, 'utf-8');
|
||||
const sessionData = JSON.parse(sessionContent);
|
||||
|
||||
// Session files have: session_number, timestamp, subtasks_completed,
|
||||
// discoveries, what_worked, what_failed, recommendations_for_next_session
|
||||
if (sessionData.session_number !== undefined) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-${sessionFile}`,
|
||||
type: 'session_insight',
|
||||
timestamp: sessionData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
discoveries: sessionData.discoveries,
|
||||
what_worked: sessionData.what_worked,
|
||||
what_failed: sessionData.what_failed,
|
||||
recommendations: sessionData.recommendations_for_next_session,
|
||||
subtasks_completed: sessionData.subtasks_completed
|
||||
}, null, 2),
|
||||
session_number: sessionData.session_number
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also load codebase_map.json as a memory item
|
||||
const codebaseMapPath = path.join(memoryDir, 'codebase_map.json');
|
||||
if (existsSync(codebaseMapPath)) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
const memData = JSON.parse(memContent);
|
||||
|
||||
if (memData.insights) {
|
||||
const mapContent = readFileSync(codebaseMapPath, 'utf-8');
|
||||
const mapData = JSON.parse(mapContent);
|
||||
if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-${memFile}`,
|
||||
type: 'session_insight',
|
||||
timestamp: memData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify(memData.insights, null, 2),
|
||||
session_number: memData.session_number
|
||||
id: `${specDir}-codebase_map`,
|
||||
type: 'codebase_map',
|
||||
timestamp: mapData.last_updated || new Date().toISOString(),
|
||||
content: JSON.stringify(mapData.discovered_files, null, 2),
|
||||
session_number: undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -14,16 +14,22 @@ import {
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info
|
||||
Info,
|
||||
Palette,
|
||||
Bot,
|
||||
FolderOpen,
|
||||
Bell,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
FullScreenDialog,
|
||||
FullScreenDialogContent,
|
||||
FullScreenDialogHeader,
|
||||
FullScreenDialogBody,
|
||||
FullScreenDialogFooter,
|
||||
FullScreenDialogTitle,
|
||||
FullScreenDialogDescription
|
||||
} from './ui/full-screen-dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
@@ -37,6 +43,7 @@ import {
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { Separator } from './ui/separator';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useSettingsStore, saveSettings, loadSettings } from '../stores/settings-store';
|
||||
import { AVAILABLE_MODELS } from '../../shared/constants';
|
||||
import type {
|
||||
@@ -51,12 +58,31 @@ interface AppSettingsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type SettingsSection = 'appearance' | 'agent' | 'paths' | 'api-keys' | 'framework' | 'notifications';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and visual preferences' },
|
||||
{ id: 'agent', label: 'Agent Settings', icon: Bot, description: 'Default model and parallelism' },
|
||||
{ id: 'paths', label: 'Paths', icon: FolderOpen, description: 'Python and framework paths' },
|
||||
{ id: 'api-keys', label: 'API Keys', icon: Key, description: 'Global API credentials' },
|
||||
{ id: 'framework', label: 'Framework', icon: Package, description: 'Auto Claude updates' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell, description: 'Alert preferences' }
|
||||
];
|
||||
|
||||
export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps) {
|
||||
const currentSettings = useSettingsStore((state) => state.settings);
|
||||
const [settings, setSettings] = useState<AppSettingsType>(currentSettings);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||
|
||||
// Auto Claude source update state
|
||||
const [sourceUpdateCheck, setSourceUpdateCheck] = useState<AutoBuildSourceUpdateCheck | null>(null);
|
||||
@@ -164,82 +190,59 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Settings className="h-5 w-5" />
|
||||
Application Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure global application settings
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 min-h-0">
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Appearance</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme" className="text-sm font-medium text-foreground">Theme</Label>
|
||||
<Select
|
||||
value={settings.theme}
|
||||
onValueChange={(value: 'light' | 'dark' | 'system') =>
|
||||
setSettings({ ...settings, theme: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
{getThemeIcon(settings.theme)}
|
||||
{settings.theme === 'system'
|
||||
? 'System'
|
||||
: settings.theme === 'dark'
|
||||
? 'Dark'
|
||||
: 'Light'}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
System
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<span className="flex items-center gap-2">
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSection) {
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">Customize how Auto Claude looks</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="theme" className="text-sm font-medium text-foreground">Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">Choose your preferred color scheme</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSettings({ ...settings, theme })}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
|
||||
settings.theme === theme
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{getThemeIcon(theme)}
|
||||
<span className="text-sm font-medium capitalize">{theme}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Default Agent Settings */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Default Agent Settings</h3>
|
||||
<div className="space-y-2">
|
||||
case 'agent':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Default Agent Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure defaults for new projects</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="defaultModel" className="text-sm font-medium text-foreground">Default Model</Label>
|
||||
<p className="text-sm text-muted-foreground">The AI model used for agent tasks</p>
|
||||
<Select
|
||||
value={settings.defaultModel}
|
||||
onValueChange={(value) =>
|
||||
setSettings({ ...settings, defaultModel: value })
|
||||
}
|
||||
onValueChange={(value) => setSettings({ ...settings, defaultModel: value })}
|
||||
>
|
||||
<SelectTrigger id="defaultModel">
|
||||
<SelectTrigger id="defaultModel" className="w-full max-w-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -251,13 +254,15 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="defaultParallelism" className="text-sm font-medium text-foreground">Default Parallelism</Label>
|
||||
<p className="text-sm text-muted-foreground">Number of concurrent agent workers (1-8)</p>
|
||||
<Input
|
||||
id="defaultParallelism"
|
||||
type="number"
|
||||
min={1}
|
||||
max={8}
|
||||
className="w-full max-w-md"
|
||||
value={settings.defaultParallelism}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
@@ -267,64 +272,70 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'paths':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Paths</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure executable and framework paths</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Paths */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Paths</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="pythonPath" className="text-sm font-medium text-foreground">Python Path</Label>
|
||||
<p className="text-sm text-muted-foreground">Path to Python executable (leave empty for default)</p>
|
||||
<Input
|
||||
id="pythonPath"
|
||||
placeholder="python3 (default)"
|
||||
className="w-full max-w-lg"
|
||||
value={settings.pythonPath || ''}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, pythonPath: e.target.value })
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, pythonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Path to Python executable (leave empty for default)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="autoBuildPath" className="text-sm font-medium text-foreground">Auto Claude Path</Label>
|
||||
<p className="text-sm text-muted-foreground">Relative path to auto-claude directory in projects</p>
|
||||
<Input
|
||||
id="autoBuildPath"
|
||||
placeholder="auto-claude (default)"
|
||||
className="w-full max-w-lg"
|
||||
value={settings.autoBuildPath || ''}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, autoBuildPath: e.target.value })
|
||||
}
|
||||
onChange={(e) => setSettings({ ...settings, autoBuildPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Relative path to auto-claude directory in projects
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'api-keys':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Global API Keys</h3>
|
||||
<p className="text-sm text-muted-foreground">Set API keys to use across all projects</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-info flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keys set here will be used as defaults. Individual projects can override these in their settings.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Global API Keys */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Global API Keys</h3>
|
||||
</div>
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-info flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set API keys here to use them across all projects. Individual projects can override these in their settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="globalClaudeToken" className="text-sm font-medium text-foreground">
|
||||
Claude OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get your token by running <code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">claude setup-token</code>
|
||||
</p>
|
||||
<div className="relative max-w-lg">
|
||||
<Input
|
||||
id="globalClaudeToken"
|
||||
type={showGlobalClaudeToken ? 'text' : 'password'}
|
||||
@@ -343,15 +354,15 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
{showGlobalClaudeToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your token by running <code className="px-1 py-0.5 bg-muted rounded font-mono">claude setup-token</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="globalOpenAIKey" className="text-sm font-medium text-foreground">
|
||||
OpenAI API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Required for Graphiti memory backend (embeddings)
|
||||
</p>
|
||||
<div className="relative max-w-lg">
|
||||
<Input
|
||||
id="globalOpenAIKey"
|
||||
type={showGlobalOpenAIKey ? 'text' : 'password'}
|
||||
@@ -370,57 +381,60 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
{showGlobalOpenAIKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for Graphiti memory backend (embeddings)
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'framework':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Auto Claude Framework</h3>
|
||||
<p className="text-sm text-muted-foreground">Manage framework updates and settings</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Auto Claude Framework Updates */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Auto Claude Framework</h3>
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 space-y-3">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-5 space-y-4">
|
||||
{isCheckingSourceUpdate ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
) : sourceUpdateCheck ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Current Version: {sourceUpdateCheck.currentVersion}
|
||||
<p className="text-base font-medium text-foreground">
|
||||
Version {sourceUpdateCheck.currentVersion}
|
||||
</p>
|
||||
{sourceUpdateCheck.latestVersion && sourceUpdateCheck.updateAvailable && (
|
||||
<p className="text-xs text-info">
|
||||
<p className="text-sm text-info mt-1">
|
||||
New version available: {sourceUpdateCheck.latestVersion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{sourceUpdateCheck.updateAvailable ? (
|
||||
<AlertCircle className="h-5 w-5 text-info" />
|
||||
<AlertCircle className="h-6 w-6 text-info" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
<CheckCircle2 className="h-6 w-6 text-success" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sourceUpdateCheck.error && (
|
||||
<p className="text-xs text-destructive">{sourceUpdateCheck.error}</p>
|
||||
<p className="text-sm text-destructive">{sourceUpdateCheck.error}</p>
|
||||
)}
|
||||
|
||||
{!sourceUpdateCheck.updateAvailable && !sourceUpdateCheck.error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You're running the latest version of the Auto Claude framework.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sourceUpdateCheck.updateAvailable && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 pt-2">
|
||||
{sourceUpdateCheck.releaseNotes && (
|
||||
<div className="text-xs text-muted-foreground bg-background rounded p-2 max-h-24 overflow-y-auto">
|
||||
<div className="text-sm text-muted-foreground bg-background rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap font-sans">
|
||||
{sourceUpdateCheck.releaseNotes}
|
||||
</pre>
|
||||
@@ -428,8 +442,8 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
)}
|
||||
|
||||
{isDownloadingUpdate ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>{downloadProgress?.message || 'Downloading...'}</span>
|
||||
</div>
|
||||
@@ -438,20 +452,17 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
)}
|
||||
</div>
|
||||
) : downloadProgress?.stage === 'complete' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<div className="flex items-center gap-3 text-sm text-success">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{downloadProgress.message}</span>
|
||||
</div>
|
||||
) : downloadProgress?.stage === 'error' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="flex items-center gap-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{downloadProgress.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleDownloadSourceUpdate}
|
||||
>
|
||||
<Button onClick={handleDownloadSourceUpdate}>
|
||||
<CloudDownload className="mr-2 h-4 w-4" />
|
||||
Download Update
|
||||
</Button>
|
||||
@@ -460,33 +471,29 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Unable to check for updates
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={checkForSourceUpdates}
|
||||
disabled={isCheckingSourceUpdate}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-3 w-3 ${isCheckingSourceUpdate ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', isCheckingSourceUpdate && 'animate-spin')} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Updates the bundled Auto Claude framework from GitHub. Individual projects can then be updated from Project Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">Auto-Update Projects</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-foreground">Auto-Update Projects</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically update Auto Claude in projects when a new version is available
|
||||
</p>
|
||||
</div>
|
||||
@@ -497,94 +504,120 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notifications */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Default Notifications</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal text-foreground">On Task Complete</Label>
|
||||
<Switch
|
||||
checked={settings.notifications.onTaskComplete}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
onTaskComplete: checked
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal text-foreground">On Task Failed</Label>
|
||||
<Switch
|
||||
checked={settings.notifications.onTaskFailed}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
onTaskFailed: checked
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal text-foreground">On Review Needed</Label>
|
||||
<Switch
|
||||
checked={settings.notifications.onReviewNeeded}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
onReviewNeeded: checked
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal text-foreground">Sound</Label>
|
||||
<Switch
|
||||
checked={settings.notifications.sound}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
sound: checked
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-[var(--error-light)] border border-[var(--error)]/30 p-3 text-sm text-[var(--error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||
Version {version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
case 'notifications':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">Notifications</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure default notification preferences</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ key: 'onTaskComplete', label: 'On Task Complete', description: 'Notify when a task finishes successfully' },
|
||||
{ key: 'onTaskFailed', label: 'On Task Failed', description: 'Notify when a task encounters an error' },
|
||||
{ key: 'onReviewNeeded', label: 'On Review Needed', description: 'Notify when QA requires your review' },
|
||||
{ key: 'sound', label: 'Sound', description: 'Play sound with notifications' }
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-foreground">{item.label}</Label>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications[item.key as keyof typeof settings.notifications]}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
notifications: {
|
||||
...settings.notifications,
|
||||
[item.key]: checked
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullScreenDialog open={open} onOpenChange={onOpenChange}>
|
||||
<FullScreenDialogContent>
|
||||
<FullScreenDialogHeader>
|
||||
<FullScreenDialogTitle className="flex items-center gap-3">
|
||||
<Settings className="h-6 w-6" />
|
||||
Settings
|
||||
</FullScreenDialogTitle>
|
||||
<FullScreenDialogDescription>
|
||||
Configure application-wide settings and preferences
|
||||
</FullScreenDialogDescription>
|
||||
</FullScreenDialogHeader>
|
||||
|
||||
<FullScreenDialogBody>
|
||||
<div className="flex h-full">
|
||||
{/* Navigation sidebar */}
|
||||
<nav className="w-64 border-r border-border bg-muted/30 p-4">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={cn(
|
||||
'w-full flex items-start gap-3 p-3 rounded-lg text-left transition-all',
|
||||
activeSection === item.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{item.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version at bottom */}
|
||||
{version && (
|
||||
<div className="mt-8 pt-4 border-t border-border">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Version {version}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-8 max-w-2xl">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenDialogBody>
|
||||
|
||||
<FullScreenDialogFooter>
|
||||
{error && (
|
||||
<div className="flex-1 rounded-lg bg-destructive/10 border border-destructive/30 px-4 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -601,8 +634,8 @@ export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FullScreenDialogFooter>
|
||||
</FullScreenDialogContent>
|
||||
</FullScreenDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const serviceTypeColors: Record<string, string> = {
|
||||
const memoryTypeIcons: Record<string, React.ElementType> = {
|
||||
session_insight: Lightbulb,
|
||||
codebase_discovery: FolderTree,
|
||||
codebase_map: FolderTree,
|
||||
pattern: Code,
|
||||
gotcha: AlertTriangle
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
Github,
|
||||
FileText,
|
||||
Sparkles,
|
||||
GitBranch
|
||||
GitBranch,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -281,14 +282,6 @@ export function Sidebar({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle theme</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onSettingsClick}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -385,8 +378,39 @@ export function Sidebar({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* New Task button */}
|
||||
<div className="p-4">
|
||||
{/* Bottom section with Settings, Help, and New Task */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Settings and Help row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 justify-start gap-2"
|
||||
onClick={onSettingsClick}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Application Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.open('https://github.com/anthropics/claude-code/issues', '_blank')}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Help & Feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* New Task button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onNewTaskClick}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, Loader2, CheckCircle2, AlertCircle, Clock, Code2, Search, Wrench } from 'lucide-react';
|
||||
import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, Loader2, CheckCircle2, AlertCircle, Clock, Code2, Search, Wrench, Pencil } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTerminalStore, type TerminalStatus } from '../stores/terminal-store';
|
||||
@@ -65,6 +65,11 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
const isCreatingRef = useRef(false);
|
||||
const isCreatedRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Title editing state
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
|
||||
const terminal = useTerminalStore((state) => state.terminals.find((t) => t.id === id));
|
||||
const setTerminalStatus = useTerminalStore((state) => state.setTerminalStatus);
|
||||
@@ -368,6 +373,7 @@ export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate,
|
||||
}, [onActivate]);
|
||||
|
||||
// Handle task selection from dropdown
|
||||
/* eslint-disable react-hooks/preserve-manual-memoization -- Complex callback with mutable tasks array */
|
||||
const handleTaskSelect = useCallback((taskId: string) => {
|
||||
const selectedTask = tasks.find((t) => t.id === taskId);
|
||||
if (!selectedTask) return;
|
||||
@@ -387,6 +393,7 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
// Send the context message to the terminal
|
||||
window.electronAPI.sendTerminalInput(id, contextMessage + '\r');
|
||||
}, [id, tasks, setAssociatedTask, updateTerminal]);
|
||||
/* eslint-enable react-hooks/preserve-manual-memoization */
|
||||
|
||||
// Handle clearing the associated task
|
||||
const handleClearTask = useCallback(() => {
|
||||
@@ -399,6 +406,40 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
const phaseConfig = PHASE_CONFIG[executionPhase];
|
||||
const PhaseIcon = phaseConfig.icon;
|
||||
|
||||
// Title editing handlers
|
||||
const handleStartEditTitle = useCallback(() => {
|
||||
setEditedTitle(terminal?.title || 'Terminal');
|
||||
setIsEditingTitle(true);
|
||||
// Focus the input after state update
|
||||
setTimeout(() => {
|
||||
titleInputRef.current?.focus();
|
||||
titleInputRef.current?.select();
|
||||
}, 0);
|
||||
}, [terminal?.title]);
|
||||
|
||||
const handleSaveTitle = useCallback(() => {
|
||||
const trimmedTitle = editedTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== terminal?.title) {
|
||||
updateTerminal(id, { title: trimmedTitle });
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
}, [editedTitle, terminal?.title, updateTerminal, id]);
|
||||
|
||||
const handleCancelEditTitle = useCallback(() => {
|
||||
setIsEditingTitle(false);
|
||||
setEditedTitle('');
|
||||
}, []);
|
||||
|
||||
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveTitle();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelEditTitle();
|
||||
}
|
||||
}, [handleSaveTitle, handleCancelEditTitle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setDropRef}
|
||||
@@ -424,24 +465,58 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
<div className={cn('h-2 w-2 rounded-full', STATUS_COLORS[terminal?.status || 'idle'])} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TerminalSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/* Terminal title with optional tooltip showing task description */}
|
||||
{associatedTask ? (
|
||||
{/* Terminal title - editable on double-click */}
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onBlur={handleSaveTitle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs font-medium text-foreground bg-transparent border border-primary/50 rounded px-1 py-0.5 outline-none focus:border-primary max-w-32"
|
||||
style={{ width: `${Math.max(editedTitle.length * 6 + 16, 60)}px` }}
|
||||
/>
|
||||
) : associatedTask ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-32 cursor-help">
|
||||
<span
|
||||
className="text-xs font-medium text-foreground truncate max-w-32 cursor-text hover:text-primary/80 transition-colors"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditTitle();
|
||||
}}
|
||||
>
|
||||
{terminal?.title || 'Terminal'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="text-sm">{associatedTask.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Double-click to rename</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-32">
|
||||
{terminal?.title || 'Terminal'}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-xs font-medium text-foreground truncate max-w-32 cursor-text hover:text-primary/80 transition-colors"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEditTitle();
|
||||
}}
|
||||
>
|
||||
{terminal?.title || 'Terminal'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">Double-click to rename</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{terminal?.isClaudeMode && (
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const FullScreenDialog = DialogPrimitive.Root;
|
||||
|
||||
const FullScreenDialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const FullScreenDialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const FullScreenDialogClose = DialogPrimitive.Close;
|
||||
|
||||
const FullScreenDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogOverlay.displayName = 'FullScreenDialogOverlay';
|
||||
|
||||
const FullScreenDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<FullScreenDialogPortal>
|
||||
<FullScreenDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-4 z-50 flex flex-col',
|
||||
'bg-card border border-border rounded-2xl',
|
||||
'shadow-2xl overflow-hidden',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98',
|
||||
'duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-2',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:pointer-events-none z-10'
|
||||
)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</FullScreenDialogPortal>
|
||||
));
|
||||
FullScreenDialogContent.displayName = 'FullScreenDialogContent';
|
||||
|
||||
const FullScreenDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 p-6 pb-4 border-b border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogHeader.displayName = 'FullScreenDialogHeader';
|
||||
|
||||
const FullScreenDialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex-1 overflow-hidden', className)} {...props} />
|
||||
);
|
||||
FullScreenDialogBody.displayName = 'FullScreenDialogBody';
|
||||
|
||||
const FullScreenDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 p-6 pt-4 border-t border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogFooter.displayName = 'FullScreenDialogFooter';
|
||||
|
||||
const FullScreenDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-xl font-semibold leading-none tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogTitle.displayName = 'FullScreenDialogTitle';
|
||||
|
||||
const FullScreenDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogDescription.displayName = 'FullScreenDialogDescription';
|
||||
|
||||
export {
|
||||
FullScreenDialog,
|
||||
FullScreenDialogPortal,
|
||||
FullScreenDialogOverlay,
|
||||
FullScreenDialogClose,
|
||||
FullScreenDialogTrigger,
|
||||
FullScreenDialogContent,
|
||||
FullScreenDialogHeader,
|
||||
FullScreenDialogBody,
|
||||
FullScreenDialogFooter,
|
||||
FullScreenDialogTitle,
|
||||
FullScreenDialogDescription,
|
||||
};
|
||||
@@ -801,3 +801,39 @@ body {
|
||||
background: #1A1A1F;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Full Screen Dialog Animations
|
||||
============================================ */
|
||||
|
||||
/* Zoom in/out animations for full-screen dialogs */
|
||||
@keyframes zoom-in-98 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoom-out-98 {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply animations via data attributes (Radix UI pattern) */
|
||||
[data-state="open"].animate-in.zoom-in-98 {
|
||||
animation: zoom-in-98 0.2s ease-out;
|
||||
}
|
||||
|
||||
[data-state="closed"].animate-out.zoom-out-98 {
|
||||
animation: zoom-out-98 0.2s ease-in;
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ export interface GraphitiMemoryState {
|
||||
|
||||
export interface MemoryEpisode {
|
||||
id: string;
|
||||
type: 'session_insight' | 'codebase_discovery' | 'pattern' | 'gotcha';
|
||||
type: 'session_insight' | 'codebase_discovery' | 'codebase_map' | 'pattern' | 'gotcha';
|
||||
timestamp: string;
|
||||
content: string;
|
||||
session_number?: number;
|
||||
|
||||
+3
-41
@@ -86,7 +86,6 @@ from task_logger import (
|
||||
get_task_logger,
|
||||
clear_task_logger,
|
||||
)
|
||||
from task_tool import TaskToolCoordinator
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -868,11 +867,13 @@ async def run_autonomous_agent(
|
||||
max_iterations: Optional[int] = None,
|
||||
verbose: bool = False,
|
||||
source_spec_dir: Optional[Path] = None,
|
||||
max_parallel_subtasks: int = 1,
|
||||
) -> None:
|
||||
"""
|
||||
Run the autonomous agent loop with automatic memory management.
|
||||
|
||||
The agent can use subagents (via Task tool) for parallel execution if needed.
|
||||
This is decided by the agent itself based on the task complexity.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the spec (auto-claude/specs/001-name/)
|
||||
@@ -880,7 +881,6 @@ async def run_autonomous_agent(
|
||||
max_iterations: Maximum number of iterations (None for unlimited)
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
max_parallel_subtasks: Maximum parallel subtasks (1=sequential, 2-10=parallel)
|
||||
"""
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
@@ -1053,44 +1053,6 @@ async def run_autonomous_agent(
|
||||
task_logger.end_phase(LogPhase.PLANNING, success=True, message="Implementation plan created")
|
||||
task_logger.start_phase(LogPhase.CODING, "Starting implementation...")
|
||||
|
||||
# Check if parallel execution is requested
|
||||
if max_parallel_subtasks > 1:
|
||||
print_status(f"Parallel mode: Using TaskToolCoordinator with {max_parallel_subtasks} workers", "info")
|
||||
|
||||
coordinator = TaskToolCoordinator(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
model=model,
|
||||
max_parallel=max_parallel_subtasks,
|
||||
)
|
||||
|
||||
# Run parallel build loop
|
||||
while True:
|
||||
completed, failed = await coordinator.run_next_batch()
|
||||
|
||||
if completed == 0 and failed == 0:
|
||||
# No more pending subtasks
|
||||
break
|
||||
|
||||
print(f"Batch complete: {completed} succeeded, {failed} failed")
|
||||
|
||||
# Check if build is complete
|
||||
if is_build_complete(spec_dir):
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
return
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# After parallel loop completes, check final status
|
||||
if is_build_complete(spec_dir):
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
else:
|
||||
print_status("Parallel build incomplete - some subtasks may need retry", "warning")
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return
|
||||
|
||||
if not next_subtask:
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
@@ -217,12 +217,25 @@ class Phase:
|
||||
depends_on: list[int] = field(default_factory=list)
|
||||
parallel_safe: bool = False # Can subtasks in this phase run in parallel?
|
||||
|
||||
# Backwards compatibility: chunks is an alias for subtasks
|
||||
@property
|
||||
def chunks(self) -> list[Subtask]:
|
||||
"""Alias for subtasks (backwards compatibility)."""
|
||||
return self.subtasks
|
||||
|
||||
@chunks.setter
|
||||
def chunks(self, value: list[Subtask]):
|
||||
"""Alias for subtasks (backwards compatibility)."""
|
||||
self.subtasks = value
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
result = {
|
||||
"phase": self.phase,
|
||||
"name": self.name,
|
||||
"type": self.type.value,
|
||||
"subtasks": [s.to_dict() for s in self.subtasks],
|
||||
# Also include 'chunks' for backwards compatibility
|
||||
"chunks": [s.to_dict() for s in self.subtasks],
|
||||
}
|
||||
if self.depends_on:
|
||||
result["depends_on"] = self.depends_on
|
||||
@@ -233,11 +246,13 @@ class Phase:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict, fallback_phase: int = 1) -> "Phase":
|
||||
"""Create Phase from dict. Uses fallback_phase if 'phase' field is missing."""
|
||||
# Support both 'subtasks' and 'chunks' keys for backwards compatibility
|
||||
subtask_data = data.get("subtasks", data.get("chunks", []))
|
||||
return cls(
|
||||
phase=data.get("phase", fallback_phase),
|
||||
name=data.get("name", f"Phase {fallback_phase}"),
|
||||
type=PhaseType(data.get("type", "implementation")),
|
||||
subtasks=[Subtask.from_dict(s) for s in data.get("subtasks", [])],
|
||||
subtasks=[Subtask.from_dict(s) for s in subtask_data],
|
||||
depends_on=data.get("depends_on", []),
|
||||
parallel_safe=data.get("parallel_safe", False),
|
||||
)
|
||||
@@ -250,6 +265,11 @@ class Phase:
|
||||
"""Get subtasks that can be worked on."""
|
||||
return [s for s in self.subtasks if s.status == SubtaskStatus.PENDING]
|
||||
|
||||
# Backwards compatibility alias
|
||||
def get_pending_chunks(self) -> list[Subtask]:
|
||||
"""Alias for get_pending_subtasks (backwards compatibility)."""
|
||||
return self.get_pending_subtasks()
|
||||
|
||||
def get_progress(self) -> tuple[int, int]:
|
||||
"""Get (completed, total) subtask counts."""
|
||||
done = sum(1 for s in self.subtasks if s.status == SubtaskStatus.COMPLETED)
|
||||
@@ -815,3 +835,13 @@ if __name__ == "__main__":
|
||||
# Load and display existing plan
|
||||
plan = ImplementationPlan.load(Path(sys.argv[1]))
|
||||
print(plan.get_status_summary())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BACKWARDS COMPATIBILITY ALIASES
|
||||
# =============================================================================
|
||||
# These aliases maintain compatibility with code that uses the old "chunk"
|
||||
# terminology. New code should use Subtask/SubtaskStatus.
|
||||
|
||||
Chunk = Subtask
|
||||
ChunkStatus = SubtaskStatus
|
||||
|
||||
@@ -365,6 +365,35 @@ Update `implementation_plan.json`:
|
||||
"status": "in_progress"
|
||||
```
|
||||
|
||||
### Using Subagents for Complex Work (Optional)
|
||||
|
||||
**For complex subtasks**, you can spawn subagents to work in parallel. Subagents are lightweight Claude Code instances that:
|
||||
- Have their own isolated context windows
|
||||
- Can work on different parts of the subtask simultaneously
|
||||
- Report back to you (the orchestrator)
|
||||
|
||||
**When to use subagents:**
|
||||
- Implementing multiple independent files in a subtask
|
||||
- Research/exploration of different parts of the codebase
|
||||
- Running different types of verification in parallel
|
||||
- Large subtasks that can be logically divided
|
||||
|
||||
**How to spawn subagents:**
|
||||
```
|
||||
Use the Task tool to spawn a subagent:
|
||||
"Implement the database schema changes in models.py"
|
||||
"Research how authentication is handled in the existing codebase"
|
||||
"Run tests for the API endpoints while I work on the frontend"
|
||||
```
|
||||
|
||||
**Best practices:**
|
||||
- Let Claude Code decide the parallelism level (don't specify batch sizes)
|
||||
- Subagents work best on disjoint tasks (different files/modules)
|
||||
- Each subagent has its own context window - use this for large codebases
|
||||
- You can spawn up to 10 concurrent subagents
|
||||
|
||||
**Note:** For simple subtasks, sequential implementation is usually sufficient. Subagents add value when there's genuinely parallel work to be done.
|
||||
|
||||
### Implementation Rules
|
||||
|
||||
1. **Match patterns exactly** - Use the same style as patterns_from files
|
||||
|
||||
+21
-45
@@ -465,7 +465,6 @@ Examples:
|
||||
python auto-claude/run.py --spec 001 --discard # Delete build (with confirmation)
|
||||
|
||||
# Advanced options
|
||||
python auto-claude/run.py --spec 001 --parallel 2 # Use 2 parallel workers
|
||||
python auto-claude/run.py --spec 001 --direct # Skip workspace isolation
|
||||
python auto-claude/run.py --spec 001 --isolated # Force workspace isolation
|
||||
|
||||
@@ -524,13 +523,6 @@ Environment Variables:
|
||||
help="Enable verbose output",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of parallel workers (default: 1 = sequential). Use 2-3 for parallelism.",
|
||||
)
|
||||
|
||||
# Workspace options
|
||||
workspace_group = parser.add_mutually_exclusive_group()
|
||||
workspace_group.add_argument(
|
||||
@@ -1000,11 +992,6 @@ def main() -> None:
|
||||
print(f"Spec: {spec_dir.name}")
|
||||
print(f"Model: {args.model}")
|
||||
|
||||
if args.parallel > 1:
|
||||
print(f"Parallel mode: {args.parallel} workers")
|
||||
else:
|
||||
print("Sequential mode: 1 worker")
|
||||
|
||||
if args.max_iterations:
|
||||
print(f"Max iterations: {args.max_iterations}")
|
||||
else:
|
||||
@@ -1072,46 +1059,36 @@ def main() -> None:
|
||||
worktree_manager = None
|
||||
source_spec_dir = None # Track original spec dir for syncing back from worktree
|
||||
|
||||
if args.parallel > 1:
|
||||
# Parallel mode always uses worktrees (managed by coordinator)
|
||||
workspace_mode = WorkspaceMode.ISOLATED
|
||||
print("Parallel mode uses isolated workspaces automatically.")
|
||||
else:
|
||||
# Sequential mode - let user choose (or auto-select if --auto-continue)
|
||||
workspace_mode = choose_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
force_isolated=args.isolated,
|
||||
force_direct=args.direct,
|
||||
auto_continue=args.auto_continue,
|
||||
# Let user choose workspace mode (or auto-select if --auto-continue)
|
||||
workspace_mode = choose_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
force_isolated=args.isolated,
|
||||
force_direct=args.direct,
|
||||
auto_continue=args.auto_continue,
|
||||
)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
|
||||
working_dir, worktree_manager, localized_spec_dir = setup_workspace(
|
||||
project_dir, spec_dir.name, workspace_mode, source_spec_dir=spec_dir
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
spec_dir = localized_spec_dir
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
|
||||
working_dir, worktree_manager, localized_spec_dir = setup_workspace(
|
||||
project_dir, spec_dir.name, workspace_mode, source_spec_dir=spec_dir
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
spec_dir = localized_spec_dir
|
||||
|
||||
# Run the autonomous agent (sequential or parallel)
|
||||
# Run the autonomous agent
|
||||
debug_section("run.py", "Starting Build Execution")
|
||||
debug("run.py", "Build configuration",
|
||||
parallel=args.parallel,
|
||||
model=args.model,
|
||||
workspace_mode=str(workspace_mode),
|
||||
working_dir=str(working_dir),
|
||||
spec_dir=str(spec_dir))
|
||||
|
||||
try:
|
||||
# Both sequential and parallel modes now use run_autonomous_agent
|
||||
# with max_parallel_subtasks parameter
|
||||
execution_mode = "parallel" if args.parallel > 1 else "sequential"
|
||||
debug("run.py", f"Starting {execution_mode} execution",
|
||||
max_parallel_subtasks=args.parallel)
|
||||
debug("run.py", "Starting agent execution")
|
||||
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
@@ -1121,10 +1098,9 @@ def main() -> None:
|
||||
max_iterations=args.max_iterations,
|
||||
verbose=args.verbose,
|
||||
source_spec_dir=source_spec_dir, # For syncing progress back to main project
|
||||
max_parallel_subtasks=args.parallel, # Pass parallel count
|
||||
)
|
||||
)
|
||||
debug_success("run.py", f"{execution_mode.capitalize()} execution completed")
|
||||
debug_success("run.py", "Agent execution completed")
|
||||
|
||||
# Run QA validation BEFORE finalization (while worktree still exists)
|
||||
# QA must sign off before the build is considered complete
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""
|
||||
Task Tool Integration for Parallel Subtask Execution
|
||||
=====================================================
|
||||
|
||||
Wrapper around Claude Code SDK's Task tool for spawning parallel subagents.
|
||||
This replaces the complex SwarmCoordinator with a simpler approach that
|
||||
leverages Claude Code's built-in parallel execution capabilities.
|
||||
|
||||
Design Principles:
|
||||
- SDK handles subagent spawning, isolation, and coordination
|
||||
- Python orchestrator only needs to generate prompts and monitor progress
|
||||
- All work happens in the spec worktree (no worker branches)
|
||||
- Fallback to sequential execution if parallel isn't needed/available
|
||||
|
||||
Usage:
|
||||
coordinator = TaskToolCoordinator(client, spec_dir, project_dir)
|
||||
await coordinator.run_subtasks_parallel(subtasks, max_parallel=3)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
from client import create_client
|
||||
from implementation_plan import ImplementationPlan, Subtask, SubtaskStatus
|
||||
from prompt_generator import generate_subtask_prompt, load_subtask_context, format_context_for_prompt
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskToolCoordinator:
|
||||
"""
|
||||
Coordinates parallel subtask execution using Claude Code Task tool.
|
||||
|
||||
Much simpler than SwarmCoordinator - SDK handles:
|
||||
- Subagent spawning (up to 10 parallel)
|
||||
- Isolated context windows
|
||||
- Scheduling and resource management
|
||||
- Progress tracking
|
||||
|
||||
This coordinator generates prompts and lets the agent use Task tool
|
||||
to spawn subagents for parallel work.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
model: str = "claude-sonnet-4-20250514",
|
||||
max_parallel: int = 3,
|
||||
):
|
||||
"""
|
||||
Initialize the Task Tool coordinator.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec and implementation plan
|
||||
project_dir: Root project directory (or spec worktree path)
|
||||
model: Claude model to use for subagents
|
||||
max_parallel: Maximum number of parallel subagents (1-10)
|
||||
"""
|
||||
self.spec_dir = spec_dir
|
||||
self.project_dir = project_dir
|
||||
self.model = model
|
||||
self.max_parallel = min(max(1, max_parallel), 10) # Clamp to 1-10
|
||||
|
||||
# Load implementation plan
|
||||
self.plan_file = spec_dir / "implementation_plan.json"
|
||||
self.plan: Optional[ImplementationPlan] = None
|
||||
|
||||
if self.plan_file.exists():
|
||||
self.plan = ImplementationPlan.load(self.plan_file)
|
||||
|
||||
def get_pending_subtasks(self) -> list[Subtask]:
|
||||
"""Get all pending subtasks that can be worked on."""
|
||||
if not self.plan:
|
||||
return []
|
||||
|
||||
pending = []
|
||||
for phase in self.plan.phases:
|
||||
# Check if phase dependencies are satisfied
|
||||
deps_satisfied = True
|
||||
for dep_id in phase.depends_on:
|
||||
dep_phase = next((p for p in self.plan.phases if p.id == dep_id), None)
|
||||
if dep_phase and not all(s.status == SubtaskStatus.COMPLETED for s in dep_phase.subtasks):
|
||||
deps_satisfied = False
|
||||
break
|
||||
|
||||
if deps_satisfied:
|
||||
for subtask in phase.subtasks:
|
||||
if subtask.status == SubtaskStatus.PENDING:
|
||||
pending.append(subtask)
|
||||
|
||||
return pending
|
||||
|
||||
def get_parallelizable_subtasks(self, subtasks: list[Subtask]) -> list[list[Subtask]]:
|
||||
"""
|
||||
Group subtasks into batches that can run in parallel.
|
||||
|
||||
Subtasks within the same phase can potentially run in parallel
|
||||
if they don't modify the same files.
|
||||
|
||||
Args:
|
||||
subtasks: List of pending subtasks
|
||||
|
||||
Returns:
|
||||
List of batches, where each batch can run in parallel
|
||||
"""
|
||||
if not subtasks:
|
||||
return []
|
||||
|
||||
# Group by phase
|
||||
phase_groups: dict[str, list[Subtask]] = {}
|
||||
for subtask in subtasks:
|
||||
phase_id = subtask.id.split("-")[0] if "-" in subtask.id else "default"
|
||||
if phase_id not in phase_groups:
|
||||
phase_groups[phase_id] = []
|
||||
phase_groups[phase_id].append(subtask)
|
||||
|
||||
# For now, return each phase group as a batch
|
||||
# In the future, we could do smarter file conflict detection
|
||||
batches = []
|
||||
for phase_subtasks in phase_groups.values():
|
||||
# Split into batches of max_parallel
|
||||
for i in range(0, len(phase_subtasks), self.max_parallel):
|
||||
batch = phase_subtasks[i:i + self.max_parallel]
|
||||
batches.append(batch)
|
||||
|
||||
return batches
|
||||
|
||||
async def run_subtask_session(
|
||||
self,
|
||||
subtask: Subtask,
|
||||
phase: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Run a single subtask session using the SDK.
|
||||
|
||||
Args:
|
||||
subtask: The subtask to implement
|
||||
phase: Optional phase context
|
||||
|
||||
Returns:
|
||||
True if subtask was completed successfully
|
||||
"""
|
||||
# Convert Subtask dataclass to dict for prompt generator
|
||||
subtask_dict = {
|
||||
"id": subtask.id,
|
||||
"description": subtask.description,
|
||||
"status": subtask.status.value,
|
||||
"service": getattr(subtask, "service", None),
|
||||
"files_to_modify": getattr(subtask, "files_to_modify", []),
|
||||
"files_to_create": getattr(subtask, "files_to_create", []),
|
||||
"patterns_from": getattr(subtask, "patterns_from", []),
|
||||
"verification": getattr(subtask, "verification", None),
|
||||
}
|
||||
|
||||
# Generate the subtask prompt
|
||||
prompt = generate_subtask_prompt(
|
||||
spec_dir=self.spec_dir,
|
||||
project_dir=self.project_dir,
|
||||
subtask=subtask_dict,
|
||||
phase=phase or {},
|
||||
attempt_count=0,
|
||||
recovery_hints=None,
|
||||
)
|
||||
|
||||
# Load and append context
|
||||
context = load_subtask_context(self.spec_dir, self.project_dir, subtask_dict)
|
||||
if context.get("patterns") or context.get("files_to_modify"):
|
||||
prompt += "\n\n" + format_context_for_prompt(context)
|
||||
|
||||
# Create client for this subtask
|
||||
client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.spec_dir,
|
||||
model=self.model,
|
||||
agent_type="coder",
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting subtask: {subtask.id}")
|
||||
print(f"\n[Subtask {subtask.id}] Starting...")
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Collect response
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
# Reload plan to check if subtask was marked complete
|
||||
if self.plan_file.exists():
|
||||
updated_plan = ImplementationPlan.load(self.plan_file)
|
||||
for phase in updated_plan.phases:
|
||||
for s in phase.subtasks:
|
||||
if s.id == subtask.id:
|
||||
if s.status == SubtaskStatus.COMPLETED:
|
||||
logger.info(f"Subtask {subtask.id} completed successfully")
|
||||
print(f"[Subtask {subtask.id}] Completed!")
|
||||
return True
|
||||
|
||||
logger.warning(f"Subtask {subtask.id} not marked as completed")
|
||||
print(f"[Subtask {subtask.id}] Not completed")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running subtask {subtask.id}: {e}")
|
||||
print(f"[Subtask {subtask.id}] Error: {e}")
|
||||
return False
|
||||
|
||||
async def run_subtasks_parallel(
|
||||
self,
|
||||
subtasks: list[Subtask],
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Execute subtasks in parallel using asyncio.
|
||||
|
||||
Since the Claude Code SDK doesn't directly expose Task tool spawning,
|
||||
we achieve parallelism by running multiple SDK sessions concurrently.
|
||||
|
||||
Args:
|
||||
subtasks: List of subtasks to execute
|
||||
|
||||
Returns:
|
||||
Dict mapping subtask_id to success status
|
||||
"""
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
if not subtasks:
|
||||
return results
|
||||
|
||||
# Group into parallelizable batches
|
||||
batches = self.get_parallelizable_subtasks(subtasks)
|
||||
|
||||
logger.info(f"Running {len(subtasks)} subtasks in {len(batches)} batch(es)")
|
||||
print(f"\nParallel execution: {len(subtasks)} subtasks, {len(batches)} batch(es)")
|
||||
print(f"Max parallel: {self.max_parallel}")
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
print(f"\n--- Batch {batch_idx + 1}/{len(batches)} ({len(batch)} subtasks) ---")
|
||||
|
||||
# Create tasks for parallel execution
|
||||
tasks = [
|
||||
self.run_subtask_session(subtask)
|
||||
for subtask in batch
|
||||
]
|
||||
|
||||
# Run batch in parallel
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Record results
|
||||
for subtask, result in zip(batch, batch_results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Subtask {subtask.id} failed with exception: {result}")
|
||||
results[subtask.id] = False
|
||||
else:
|
||||
results[subtask.id] = result
|
||||
|
||||
# Small delay between batches
|
||||
if batch_idx < len(batches) - 1:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return results
|
||||
|
||||
async def run_next_batch(self) -> tuple[int, int]:
|
||||
"""
|
||||
Run the next batch of pending subtasks.
|
||||
|
||||
Returns:
|
||||
(completed_count, failed_count)
|
||||
"""
|
||||
pending = self.get_pending_subtasks()
|
||||
|
||||
if not pending:
|
||||
return 0, 0
|
||||
|
||||
# Take at most max_parallel subtasks
|
||||
batch = pending[:self.max_parallel]
|
||||
|
||||
results = await self.run_subtasks_parallel(batch)
|
||||
|
||||
completed = sum(1 for v in results.values() if v)
|
||||
failed = sum(1 for v in results.values() if not v)
|
||||
|
||||
return completed, failed
|
||||
|
||||
|
||||
async def run_parallel_build(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
model: str = "claude-sonnet-4-20250514",
|
||||
max_parallel: int = 3,
|
||||
max_iterations: int = 100,
|
||||
) -> bool:
|
||||
"""
|
||||
Run the full parallel build loop.
|
||||
|
||||
This is a simplified alternative to run_autonomous_agent that uses
|
||||
parallel subtask execution.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec
|
||||
project_dir: Root project directory
|
||||
model: Claude model to use
|
||||
max_parallel: Maximum parallel subtasks
|
||||
max_iterations: Maximum number of build iterations
|
||||
|
||||
Returns:
|
||||
True if build completed successfully
|
||||
"""
|
||||
coordinator = TaskToolCoordinator(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
model=model,
|
||||
max_parallel=max_parallel,
|
||||
)
|
||||
|
||||
iteration = 0
|
||||
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Build Iteration {iteration}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
completed, failed = await coordinator.run_next_batch()
|
||||
|
||||
if completed == 0 and failed == 0:
|
||||
# No pending subtasks
|
||||
print("\nNo pending subtasks - checking build status...")
|
||||
|
||||
# Reload plan and check completion
|
||||
if coordinator.plan_file.exists():
|
||||
plan = ImplementationPlan.load(coordinator.plan_file)
|
||||
all_subtasks = [s for p in plan.phases for s in p.subtasks]
|
||||
all_completed = all(s.status == SubtaskStatus.COMPLETED for s in all_subtasks)
|
||||
|
||||
if all_completed:
|
||||
print("\nBuild complete! All subtasks finished.")
|
||||
return True
|
||||
else:
|
||||
pending = [s for s in all_subtasks if s.status == SubtaskStatus.PENDING]
|
||||
print(f"\nBuild incomplete: {len(pending)} subtasks still pending")
|
||||
print("This may be due to unsatisfied phase dependencies.")
|
||||
break
|
||||
|
||||
print(f"\nBatch results: {completed} completed, {failed} failed")
|
||||
|
||||
if failed > 0:
|
||||
print(f"Warning: {failed} subtask(s) failed in this batch")
|
||||
# Continue trying - recovery may help
|
||||
|
||||
# Small delay between iterations
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# === Convenience Functions ===
|
||||
|
||||
def get_parallel_status(spec_dir: Path) -> dict:
|
||||
"""
|
||||
Get the current parallel build status.
|
||||
|
||||
Returns dict with:
|
||||
- pending: list of pending subtask IDs
|
||||
- completed: list of completed subtask IDs
|
||||
- failed: list of failed subtask IDs
|
||||
- can_parallelize: bool indicating if parallel execution is possible
|
||||
"""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"pending": [],
|
||||
"completed": [],
|
||||
"failed": [],
|
||||
"can_parallelize": False,
|
||||
}
|
||||
|
||||
plan = ImplementationPlan.load(plan_file)
|
||||
|
||||
pending = []
|
||||
completed = []
|
||||
failed = []
|
||||
|
||||
for phase in plan.phases:
|
||||
for subtask in phase.subtasks:
|
||||
if subtask.status == SubtaskStatus.PENDING:
|
||||
pending.append(subtask.id)
|
||||
elif subtask.status == SubtaskStatus.COMPLETED:
|
||||
completed.append(subtask.id)
|
||||
elif subtask.status == SubtaskStatus.FAILED:
|
||||
failed.append(subtask.id)
|
||||
|
||||
return {
|
||||
"pending": pending,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"can_parallelize": len(pending) > 1,
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Agent Architecture
|
||||
============================
|
||||
|
||||
Verifies the agent architecture where:
|
||||
- Python orchestrator runs a single Claude SDK session
|
||||
- The agent itself decides when to spawn subagents (via Task tool)
|
||||
- Parallel execution is handled internally by Claude Code, not Python
|
||||
|
||||
Key architectural constraints:
|
||||
- No Python-level parallel orchestration (no coordinator.py, task_tool.py)
|
||||
- No --parallel CLI flag (agent decides parallelism)
|
||||
- Agent prompt includes subagent capability documentation
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add auto-claude directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude"))
|
||||
|
||||
|
||||
class TestNoExternalParallelism:
|
||||
"""Verify no Python-level parallel orchestration exists."""
|
||||
|
||||
def test_no_coordinator_module(self):
|
||||
"""No external coordinator module should exist."""
|
||||
coordinator_path = Path(__file__).parent.parent / "auto-claude" / "coordinator.py"
|
||||
assert not coordinator_path.exists(), (
|
||||
"coordinator.py should not exist. Parallel orchestration is handled "
|
||||
"internally by the agent using Claude Code's Task tool."
|
||||
)
|
||||
|
||||
def test_no_task_tool_module(self):
|
||||
"""No task_tool wrapper module should exist."""
|
||||
task_tool_path = Path(__file__).parent.parent / "auto-claude" / "task_tool.py"
|
||||
assert not task_tool_path.exists(), (
|
||||
"task_tool.py should not exist. The agent spawns subagents directly "
|
||||
"using Claude Code's built-in Task tool."
|
||||
)
|
||||
|
||||
def test_no_subtask_worker_config(self):
|
||||
"""No external subtask worker agent config should exist."""
|
||||
worker_config = Path(__file__).parent.parent / ".claude" / "agents" / "subtask-worker.md"
|
||||
assert not worker_config.exists(), (
|
||||
"subtask-worker.md should not exist. Subagents use Claude Code's "
|
||||
"built-in agent types, not custom configs."
|
||||
)
|
||||
|
||||
|
||||
class TestCLIInterface:
|
||||
"""Verify CLI doesn't expose parallel orchestration options."""
|
||||
|
||||
def test_no_parallel_flag(self):
|
||||
"""CLI should not have --parallel argument."""
|
||||
run_py_path = Path(__file__).parent.parent / "auto-claude" / "run.py"
|
||||
content = run_py_path.read_text()
|
||||
|
||||
# Check that --parallel is not defined as an argument
|
||||
assert '"--parallel"' not in content, (
|
||||
"CLI should not have --parallel flag. The agent decides when to "
|
||||
"use parallel execution via subagents."
|
||||
)
|
||||
assert "'--parallel'" not in content, (
|
||||
"CLI should not have --parallel flag. The agent decides when to "
|
||||
"use parallel execution via subagents."
|
||||
)
|
||||
|
||||
def test_no_parallel_examples_in_docs(self):
|
||||
"""CLI documentation should not mention parallel mode."""
|
||||
run_py_path = Path(__file__).parent.parent / "auto-claude" / "run.py"
|
||||
content = run_py_path.read_text()
|
||||
|
||||
# The docstring should not have --parallel examples
|
||||
assert "--parallel" not in content[:2000], (
|
||||
"CLI docs should not contain --parallel examples."
|
||||
)
|
||||
|
||||
|
||||
class TestAgentEntryPoint:
|
||||
"""Verify the agent entry point function signature."""
|
||||
|
||||
def test_no_parallel_parameters(self):
|
||||
"""Agent entry point should not accept parallel configuration."""
|
||||
from agent import run_autonomous_agent
|
||||
|
||||
sig = inspect.signature(run_autonomous_agent)
|
||||
param_names = list(sig.parameters.keys())
|
||||
|
||||
assert "max_parallel_subtasks" not in param_names, (
|
||||
"Agent should not accept max_parallel_subtasks. "
|
||||
"Parallelism is decided by the agent itself."
|
||||
)
|
||||
assert "parallel" not in param_names, (
|
||||
"Agent should not accept a 'parallel' parameter."
|
||||
)
|
||||
|
||||
def test_required_parameters(self):
|
||||
"""Agent entry point has required parameters."""
|
||||
from agent import run_autonomous_agent
|
||||
|
||||
sig = inspect.signature(run_autonomous_agent)
|
||||
param_names = list(sig.parameters.keys())
|
||||
|
||||
expected = ["project_dir", "spec_dir", "model"]
|
||||
for param in expected:
|
||||
assert param in param_names, f"Expected parameter '{param}' not found"
|
||||
|
||||
def test_is_async(self):
|
||||
"""Agent entry point is async."""
|
||||
from agent import run_autonomous_agent
|
||||
|
||||
assert inspect.iscoroutinefunction(run_autonomous_agent), (
|
||||
"run_autonomous_agent should be async"
|
||||
)
|
||||
|
||||
|
||||
class TestAgentPrompt:
|
||||
"""Verify the agent prompt documents subagent capability."""
|
||||
|
||||
def test_mentions_subagents(self):
|
||||
"""Agent prompt mentions subagent capability."""
|
||||
coder_prompt_path = Path(__file__).parent.parent / "auto-claude" / "prompts" / "coder.md"
|
||||
content = coder_prompt_path.read_text()
|
||||
|
||||
assert "subagent" in content.lower(), (
|
||||
"Agent prompt should document subagent capability for parallel work."
|
||||
)
|
||||
|
||||
def test_mentions_parallel_capability(self):
|
||||
"""Agent prompt mentions parallel/concurrent capability."""
|
||||
coder_prompt_path = Path(__file__).parent.parent / "auto-claude" / "prompts" / "coder.md"
|
||||
content = coder_prompt_path.read_text()
|
||||
|
||||
has_task_tool = "task tool" in content.lower() or "Task tool" in content
|
||||
has_parallel = "parallel" in content.lower()
|
||||
has_concurrent = "concurrent" in content.lower() or "simultaneously" in content.lower()
|
||||
|
||||
assert has_task_tool or has_parallel or has_concurrent, (
|
||||
"Agent prompt should mention parallel/concurrent work capability."
|
||||
)
|
||||
|
||||
|
||||
class TestModuleIntegrity:
|
||||
"""Verify core modules work correctly."""
|
||||
|
||||
def test_agent_module_imports(self):
|
||||
"""Agent module imports without errors."""
|
||||
try:
|
||||
import agent
|
||||
except ImportError as e:
|
||||
pytest.fail(f"agent.py failed to import: {e}")
|
||||
|
||||
def test_run_module_valid_syntax(self):
|
||||
"""Run module has valid Python syntax."""
|
||||
run_py_path = Path(__file__).parent.parent / "auto-claude" / "run.py"
|
||||
content = run_py_path.read_text()
|
||||
|
||||
try:
|
||||
ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
pytest.fail(f"run.py has syntax error: {e}")
|
||||
|
||||
def test_no_coordinator_imports(self):
|
||||
"""Core modules don't import coordinator."""
|
||||
for filename in ["run.py", "agent.py"]:
|
||||
filepath = Path(__file__).parent.parent / "auto-claude" / filename
|
||||
content = filepath.read_text()
|
||||
|
||||
assert "from coordinator import" not in content, (
|
||||
f"{filename} should not import coordinator"
|
||||
)
|
||||
assert "import coordinator" not in content, (
|
||||
f"{filename} should not import coordinator"
|
||||
)
|
||||
|
||||
def test_no_task_tool_imports(self):
|
||||
"""Core modules don't import task_tool."""
|
||||
for filename in ["run.py", "agent.py"]:
|
||||
filepath = Path(__file__).parent.parent / "auto-claude" / filename
|
||||
content = filepath.read_text()
|
||||
|
||||
assert "from task_tool import" not in content, (
|
||||
f"{filename} should not import task_tool"
|
||||
)
|
||||
assert "import task_tool" not in content, (
|
||||
f"{filename} should not import task_tool"
|
||||
)
|
||||
|
||||
|
||||
class TestProjectDocumentation:
|
||||
"""Verify project documentation is accurate."""
|
||||
|
||||
def test_no_parallel_cli_documented(self):
|
||||
"""CLAUDE.md doesn't document --parallel flag."""
|
||||
claude_md_path = Path(__file__).parent.parent / "CLAUDE.md"
|
||||
content = claude_md_path.read_text()
|
||||
|
||||
assert "--parallel 2" not in content, (
|
||||
"CLAUDE.md should not document --parallel flag"
|
||||
)
|
||||
|
||||
def test_subagent_architecture_documented(self):
|
||||
"""CLAUDE.md documents subagent-based architecture."""
|
||||
claude_md_path = Path(__file__).parent.parent / "CLAUDE.md"
|
||||
content = claude_md_path.read_text()
|
||||
|
||||
has_subagent = "subagent" in content.lower()
|
||||
has_task_tool = "task tool" in content.lower()
|
||||
|
||||
assert has_subagent or has_task_tool, (
|
||||
"CLAUDE.md should document subagent-based parallel work"
|
||||
)
|
||||
|
||||
|
||||
class TestSubtaskTerminology:
|
||||
"""Verify subtask terminology is used consistently."""
|
||||
|
||||
def test_implementation_plan_uses_subtask_class(self):
|
||||
"""Implementation plan uses Subtask class."""
|
||||
impl_plan_path = Path(__file__).parent.parent / "auto-claude" / "implementation_plan.py"
|
||||
content = impl_plan_path.read_text()
|
||||
|
||||
assert "class Subtask" in content, (
|
||||
"implementation_plan.py should define 'class Subtask'"
|
||||
)
|
||||
assert "SubtaskStatus" in content, (
|
||||
"implementation_plan.py should define SubtaskStatus enum"
|
||||
)
|
||||
|
||||
def test_progress_uses_subtask_terminology(self):
|
||||
"""Progress module uses subtask terminology."""
|
||||
progress_path = Path(__file__).parent.parent / "auto-claude" / "progress.py"
|
||||
content = progress_path.read_text()
|
||||
|
||||
assert "subtask" in content.lower(), (
|
||||
"progress.py should use subtask terminology"
|
||||
)
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests when executed directly."""
|
||||
print("\nTesting Agent Architecture")
|
||||
print("=" * 60)
|
||||
|
||||
test_classes = [
|
||||
TestNoExternalParallelism,
|
||||
TestCLIInterface,
|
||||
TestAgentEntryPoint,
|
||||
TestAgentPrompt,
|
||||
TestModuleIntegrity,
|
||||
TestProjectDocumentation,
|
||||
TestSubtaskTerminology,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test_class in test_classes:
|
||||
print(f"\n{test_class.__name__}:")
|
||||
instance = test_class()
|
||||
|
||||
for method_name in dir(instance):
|
||||
if method_name.startswith("test_"):
|
||||
method = getattr(instance, method_name)
|
||||
try:
|
||||
method()
|
||||
print(f" ✓ {method_name}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" ✗ {method_name}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ✗ {method_name}: Unexpected error: {e}")
|
||||
failed += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_tests())
|
||||
@@ -12,8 +12,8 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
# Add auto-claude directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude"))
|
||||
|
||||
from critique import (
|
||||
generate_critique_prompt,
|
||||
|
||||
+21
-21
@@ -48,8 +48,8 @@ class TestAddFollowupPhase:
|
||||
plan = ImplementationPlan(
|
||||
feature="Test Feature",
|
||||
phases=[
|
||||
Phase(phase=1, name="Phase 1", chunks=[]),
|
||||
Phase(phase=2, name="Phase 2", chunks=[]),
|
||||
Phase(phase=1, name="Phase 1", subtasks=[]),
|
||||
Phase(phase=2, name="Phase 2", subtasks=[]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -64,9 +64,9 @@ class TestAddFollowupPhase:
|
||||
plan = ImplementationPlan(
|
||||
feature="Test Feature",
|
||||
phases=[
|
||||
Phase(phase=1, name="Phase 1", chunks=[]),
|
||||
Phase(phase=2, name="Phase 2", chunks=[]),
|
||||
Phase(phase=3, name="Phase 3", chunks=[]),
|
||||
Phase(phase=1, name="Phase 1", subtasks=[]),
|
||||
Phase(phase=2, name="Phase 2", subtasks=[]),
|
||||
Phase(phase=3, name="Phase 3", subtasks=[]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ class TestAddFollowupPhase:
|
||||
"""Multiple follow-ups create sequential phase numbers."""
|
||||
plan = ImplementationPlan(
|
||||
feature="Test Feature",
|
||||
phases=[Phase(phase=1, name="Initial", chunks=[])],
|
||||
phases=[Phase(phase=1, name="Initial", subtasks=[])],
|
||||
)
|
||||
|
||||
# First follow-up
|
||||
@@ -185,7 +185,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -206,7 +206,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -227,7 +227,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -248,7 +248,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Task 2", status=ChunkStatus.COMPLETED),
|
||||
],
|
||||
@@ -271,7 +271,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[
|
||||
subtasks=[
|
||||
Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED),
|
||||
Chunk(id="c2", description="Task 2", status=ChunkStatus.PENDING),
|
||||
],
|
||||
@@ -293,7 +293,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.PENDING)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.PENDING)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -313,7 +313,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -333,7 +333,7 @@ class TestResetForFollowup:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -356,7 +356,7 @@ class TestExistingChunksPreserved:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Original Phase",
|
||||
chunks=[
|
||||
subtasks=[
|
||||
Chunk(
|
||||
id="original-1",
|
||||
description="Original task",
|
||||
@@ -384,13 +384,13 @@ class TestExistingChunksPreserved:
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
depends_on=[],
|
||||
chunks=[Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task 1", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
Phase(
|
||||
phase=2,
|
||||
name="Phase 2",
|
||||
depends_on=[1],
|
||||
chunks=[Chunk(id="c2", description="Task 2", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c2", description="Task 2", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -420,7 +420,7 @@ class TestFollowupPlanSaveLoad:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Original",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -451,7 +451,7 @@ class TestFollowupPlanSaveLoad:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Original",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -487,7 +487,7 @@ class TestFollowupProgressCalculation:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Original",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -516,7 +516,7 @@ class TestFollowupProgressCalculation:
|
||||
Phase(
|
||||
phase=1,
|
||||
name="Original",
|
||||
chunks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
subtasks=[Chunk(id="c1", description="Task", status=ChunkStatus.COMPLETED)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -191,7 +191,7 @@ class TestPhase:
|
||||
phase=1,
|
||||
name="Setup",
|
||||
type=PhaseType.SETUP,
|
||||
chunks=[chunk1, chunk2],
|
||||
subtasks=[chunk1, chunk2],
|
||||
)
|
||||
|
||||
assert phase.phase == 1
|
||||
@@ -202,7 +202,7 @@ class TestPhase:
|
||||
"""Phase completion checks all chunks."""
|
||||
chunk1 = Chunk(id="c1", description="Chunk 1", status=ChunkStatus.COMPLETED)
|
||||
chunk2 = Chunk(id="c2", description="Chunk 2", status=ChunkStatus.COMPLETED)
|
||||
phase = Phase(phase=1, name="Test", chunks=[chunk1, chunk2])
|
||||
phase = Phase(phase=1, name="Test", subtasks=[chunk1, chunk2])
|
||||
|
||||
assert phase.is_complete() is True
|
||||
|
||||
@@ -210,7 +210,7 @@ class TestPhase:
|
||||
"""Phase not complete with pending chunks."""
|
||||
chunk1 = Chunk(id="c1", description="Chunk 1", status=ChunkStatus.COMPLETED)
|
||||
chunk2 = Chunk(id="c2", description="Chunk 2", status=ChunkStatus.PENDING)
|
||||
phase = Phase(phase=1, name="Test", chunks=[chunk1, chunk2])
|
||||
phase = Phase(phase=1, name="Test", subtasks=[chunk1, chunk2])
|
||||
|
||||
assert phase.is_complete() is False
|
||||
|
||||
@@ -219,7 +219,7 @@ class TestPhase:
|
||||
chunk1 = Chunk(id="c1", description="Chunk 1", status=ChunkStatus.COMPLETED)
|
||||
chunk2 = Chunk(id="c2", description="Chunk 2", status=ChunkStatus.PENDING)
|
||||
chunk3 = Chunk(id="c3", description="Chunk 3", status=ChunkStatus.PENDING)
|
||||
phase = Phase(phase=1, name="Test", chunks=[chunk1, chunk2, chunk3])
|
||||
phase = Phase(phase=1, name="Test", subtasks=[chunk1, chunk2, chunk3])
|
||||
|
||||
pending = phase.get_pending_chunks()
|
||||
|
||||
@@ -231,7 +231,7 @@ class TestPhase:
|
||||
chunk1 = Chunk(id="c1", description="Chunk 1", status=ChunkStatus.COMPLETED)
|
||||
chunk2 = Chunk(id="c2", description="Chunk 2", status=ChunkStatus.COMPLETED)
|
||||
chunk3 = Chunk(id="c3", description="Chunk 3", status=ChunkStatus.PENDING)
|
||||
phase = Phase(phase=1, name="Test", chunks=[chunk1, chunk2, chunk3])
|
||||
phase = Phase(phase=1, name="Test", subtasks=[chunk1, chunk2, chunk3])
|
||||
|
||||
completed, total = phase.get_progress()
|
||||
|
||||
@@ -245,7 +245,7 @@ class TestPhase:
|
||||
phase=1,
|
||||
name="Setup",
|
||||
type=PhaseType.SETUP,
|
||||
chunks=[chunk],
|
||||
subtasks=[chunk],
|
||||
depends_on=[],
|
||||
)
|
||||
|
||||
@@ -499,10 +499,10 @@ class TestDependencyResolution:
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
phases=[
|
||||
Phase(phase=1, name="Setup", chunks=[
|
||||
Phase(phase=1, name="Setup", subtasks=[
|
||||
Chunk(id="c1", description="Setup", status=ChunkStatus.PENDING)
|
||||
]),
|
||||
Phase(phase=2, name="Build", depends_on=[1], chunks=[
|
||||
Phase(phase=2, name="Build", depends_on=[1], subtasks=[
|
||||
Chunk(id="c2", description="Build")
|
||||
]),
|
||||
],
|
||||
@@ -519,13 +519,13 @@ class TestDependencyResolution:
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
phases=[
|
||||
Phase(phase=1, name="Setup", chunks=[
|
||||
Phase(phase=1, name="Setup", subtasks=[
|
||||
Chunk(id="c1", description="Setup", status=ChunkStatus.COMPLETED)
|
||||
]),
|
||||
Phase(phase=2, name="Backend", depends_on=[1], chunks=[
|
||||
Phase(phase=2, name="Backend", depends_on=[1], subtasks=[
|
||||
Chunk(id="c2", description="Backend")
|
||||
]),
|
||||
Phase(phase=3, name="Frontend", depends_on=[1], chunks=[
|
||||
Phase(phase=3, name="Frontend", depends_on=[1], subtasks=[
|
||||
Chunk(id="c3", description="Frontend")
|
||||
]),
|
||||
],
|
||||
@@ -544,13 +544,13 @@ class TestDependencyResolution:
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
phases=[
|
||||
Phase(phase=1, name="Phase1", chunks=[
|
||||
Phase(phase=1, name="Phase1", subtasks=[
|
||||
Chunk(id="c1", description="C1", status=ChunkStatus.COMPLETED)
|
||||
]),
|
||||
Phase(phase=2, name="Phase2", chunks=[
|
||||
Phase(phase=2, name="Phase2", subtasks=[
|
||||
Chunk(id="c2", description="C2", status=ChunkStatus.PENDING)
|
||||
]),
|
||||
Phase(phase=3, name="Phase3", depends_on=[1, 2], chunks=[
|
||||
Phase(phase=3, name="Phase3", depends_on=[1, 2], subtasks=[
|
||||
Chunk(id="c3", description="C3")
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for parallel execution coordinator
|
||||
|
||||
Tests the SwarmCoordinator class that manages parallel chunk execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude"))
|
||||
|
||||
from coordinator import SwarmCoordinator, ParallelGroup, WorkerAssignment, WorkerStatus
|
||||
from implementation_plan import (
|
||||
ImplementationPlan,
|
||||
Phase,
|
||||
Chunk,
|
||||
ChunkStatus,
|
||||
PhaseType,
|
||||
WorkflowType,
|
||||
)
|
||||
|
||||
|
||||
class TestCoordinatorInitialization:
|
||||
"""Tests for SwarmCoordinator initialization."""
|
||||
|
||||
def test_creates_with_required_params(self):
|
||||
"""Coordinator can be initialized with required params."""
|
||||
spec_dir = Path("/tmp/test-spec")
|
||||
project_dir = Path("/tmp/test-project")
|
||||
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
)
|
||||
|
||||
assert coordinator.spec_dir == spec_dir
|
||||
assert coordinator.project_dir == project_dir
|
||||
|
||||
def test_default_max_workers(self):
|
||||
"""Default max_workers is 3."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
assert coordinator.max_workers == 3
|
||||
|
||||
def test_custom_max_workers(self):
|
||||
"""Can set custom max_workers."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
max_workers=5,
|
||||
)
|
||||
|
||||
assert coordinator.max_workers == 5
|
||||
|
||||
def test_empty_workers_and_files(self):
|
||||
"""Starts with empty workers and claimed_files."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
assert len(coordinator.workers) == 0
|
||||
assert len(coordinator.claimed_files) == 0
|
||||
|
||||
def test_progress_file_path(self):
|
||||
"""Progress file is in spec_dir."""
|
||||
spec_dir = Path("/tmp/test-spec")
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
assert coordinator.progress_file == spec_dir / "parallel_progress.json"
|
||||
|
||||
|
||||
class TestGetAvailableChunks:
|
||||
"""Tests for get_available_chunks method."""
|
||||
|
||||
def test_returns_empty_without_plan(self):
|
||||
"""Returns empty list when no plan loaded."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
assert coordinator.get_available_chunks() == []
|
||||
|
||||
def test_returns_pending_chunks_from_available_phases(self):
|
||||
"""Returns pending chunks from phases with met dependencies."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
# Create plan with one phase (no dependencies)
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
depends_on=[],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
available = coordinator.get_available_chunks()
|
||||
|
||||
assert len(available) == 1
|
||||
assert available[0] == (phase, chunk)
|
||||
|
||||
def test_excludes_completed_chunks(self):
|
||||
"""Excludes chunks that are already completed."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
status=ChunkStatus.COMPLETED,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
depends_on=[],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
available = coordinator.get_available_chunks()
|
||||
|
||||
assert len(available) == 0
|
||||
|
||||
def test_excludes_chunks_with_claimed_files(self):
|
||||
"""Excludes chunks whose files are already claimed."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
files_to_modify=["app.py"],
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
depends_on=[],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
# Claim the file
|
||||
coordinator.claimed_files["app.py"] = "other-worker"
|
||||
|
||||
available = coordinator.get_available_chunks()
|
||||
|
||||
assert len(available) == 0
|
||||
|
||||
|
||||
class TestClaimChunk:
|
||||
"""Tests for claim_chunk method."""
|
||||
|
||||
def test_claims_chunk_successfully(self):
|
||||
"""Successfully claims an unclaimed chunk."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
files_to_modify=["file1.py"],
|
||||
files_to_create=["file2.py"],
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
worktree_path = Path("/tmp/worktree")
|
||||
branch_name = "worker-1/chunk-1"
|
||||
|
||||
success = coordinator.claim_chunk(
|
||||
"worker-1", phase, chunk, worktree_path, branch_name
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert "file1.py" in coordinator.claimed_files
|
||||
assert "file2.py" in coordinator.claimed_files
|
||||
assert coordinator.claimed_files["file1.py"] == "worker-1"
|
||||
assert chunk.status == ChunkStatus.IN_PROGRESS
|
||||
|
||||
def test_fails_to_claim_already_claimed_chunk(self):
|
||||
"""Fails to claim a chunk that's already claimed."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
files_to_modify=["file1.py"],
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
# First worker claims
|
||||
coordinator.claim_chunk(
|
||||
"worker-1", phase, chunk, Path("/tmp/wt1"), "branch-1"
|
||||
)
|
||||
|
||||
# Second worker tries to claim same chunk
|
||||
success = coordinator.claim_chunk(
|
||||
"worker-2", phase, chunk, Path("/tmp/wt2"), "branch-2"
|
||||
)
|
||||
|
||||
assert success is False
|
||||
|
||||
def test_fails_when_files_already_claimed(self):
|
||||
"""Fails when chunk's files are already claimed by another worker."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
files_to_modify=["shared.py"],
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
# Pre-claim the file
|
||||
coordinator.claimed_files["shared.py"] = "other-worker"
|
||||
|
||||
success = coordinator.claim_chunk(
|
||||
"worker-1", phase, chunk, Path("/tmp/wt"), "branch-1"
|
||||
)
|
||||
|
||||
assert success is False
|
||||
|
||||
def test_creates_worker_assignment(self):
|
||||
"""Creates WorkerAssignment when claiming chunk."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
worktree_path = Path("/tmp/worktree")
|
||||
branch_name = "worker-1/chunk-1"
|
||||
|
||||
coordinator.claim_chunk("worker-1", phase, chunk, worktree_path, branch_name)
|
||||
|
||||
assert "worker-1" in coordinator.workers
|
||||
assignment = coordinator.workers["worker-1"]
|
||||
assert assignment.worker_id == "worker-1"
|
||||
assert assignment.chunk_id == "chunk-1"
|
||||
assert assignment.branch_name == branch_name
|
||||
assert assignment.worktree_path == worktree_path
|
||||
assert assignment.status == WorkerStatus.WORKING
|
||||
|
||||
|
||||
class TestReleaseChunk:
|
||||
"""Tests for release_chunk method."""
|
||||
|
||||
def test_releases_files_on_success(self):
|
||||
"""Releases claimed files when chunk completes successfully."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
files_to_modify=["file1.py"],
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
# Claim chunk
|
||||
coordinator.claim_chunk(
|
||||
"worker-1", phase, chunk, Path("/tmp/wt"), "branch-1"
|
||||
)
|
||||
|
||||
# Release chunk
|
||||
coordinator.release_chunk("worker-1", "chunk-1", success=True)
|
||||
|
||||
assert "file1.py" not in coordinator.claimed_files
|
||||
assert "worker-1" not in coordinator.workers
|
||||
assert chunk.status == ChunkStatus.COMPLETED
|
||||
|
||||
def test_marks_chunk_failed_on_failure(self):
|
||||
"""Marks chunk as failed when released with success=False."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
plan = ImplementationPlan(
|
||||
feature="Test",
|
||||
workflow_type=WorkflowType.FEATURE,
|
||||
services_involved=["backend"],
|
||||
)
|
||||
|
||||
chunk = Chunk(
|
||||
id="chunk-1",
|
||||
description="Test chunk",
|
||||
status=ChunkStatus.PENDING,
|
||||
)
|
||||
|
||||
phase = Phase(
|
||||
phase=1,
|
||||
name="Test Phase",
|
||||
chunks=[chunk],
|
||||
)
|
||||
|
||||
plan.phases = [phase]
|
||||
coordinator.plan = plan
|
||||
|
||||
coordinator.claim_chunk(
|
||||
"worker-1", phase, chunk, Path("/tmp/wt"), "branch-1"
|
||||
)
|
||||
|
||||
coordinator.release_chunk("worker-1", "chunk-1", success=False, output="Error!")
|
||||
|
||||
assert chunk.status == ChunkStatus.FAILED
|
||||
|
||||
def test_ignores_unknown_worker(self):
|
||||
"""Does nothing when releasing unknown worker."""
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=Path("/tmp/test-spec"),
|
||||
project_dir=Path("/tmp/test-project"),
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
coordinator.release_chunk("unknown-worker", "chunk-1", success=True)
|
||||
|
||||
|
||||
class TestParallelGroup:
|
||||
"""Tests for ParallelGroup dataclass."""
|
||||
|
||||
def test_validates_no_file_overlap(self):
|
||||
"""Raises error when phases have overlapping files."""
|
||||
phase1 = Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[
|
||||
Chunk(
|
||||
id="c1",
|
||||
description="Chunk 1",
|
||||
files_to_modify=["shared.py"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
phase2 = Phase(
|
||||
phase=2,
|
||||
name="Phase 2",
|
||||
chunks=[
|
||||
Chunk(
|
||||
id="c2",
|
||||
description="Chunk 2",
|
||||
files_to_modify=["shared.py"], # Same file!
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="cannot run in parallel"):
|
||||
ParallelGroup(phases=[phase1, phase2], all_dependencies_met=True)
|
||||
|
||||
def test_allows_non_overlapping_files(self):
|
||||
"""Allows phases with different files."""
|
||||
phase1 = Phase(
|
||||
phase=1,
|
||||
name="Phase 1",
|
||||
chunks=[
|
||||
Chunk(
|
||||
id="c1",
|
||||
description="Chunk 1",
|
||||
files_to_modify=["file1.py"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
phase2 = Phase(
|
||||
phase=2,
|
||||
name="Phase 2",
|
||||
chunks=[
|
||||
Chunk(
|
||||
id="c2",
|
||||
description="Chunk 2",
|
||||
files_to_modify=["file2.py"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
group = ParallelGroup(phases=[phase1, phase2], all_dependencies_met=True)
|
||||
assert len(group.phases) == 2
|
||||
|
||||
|
||||
class TestWorkerAssignment:
|
||||
"""Tests for WorkerAssignment dataclass."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Converts to dictionary correctly."""
|
||||
assignment = WorkerAssignment(
|
||||
worker_id="worker-1",
|
||||
phase_id=1,
|
||||
chunk_id="chunk-1",
|
||||
branch_name="worker-1/chunk-1",
|
||||
worktree_path=Path("/tmp/worktree"),
|
||||
status=WorkerStatus.WORKING,
|
||||
started_at="2024-01-01T10:00:00",
|
||||
)
|
||||
|
||||
result = assignment.to_dict()
|
||||
|
||||
assert result["worker_id"] == "worker-1"
|
||||
assert result["phase_id"] == 1
|
||||
assert result["chunk_id"] == "chunk-1"
|
||||
assert result["branch_name"] == "worker-1/chunk-1"
|
||||
assert result["worktree_path"] == "/tmp/worktree"
|
||||
assert result["status"] == "working"
|
||||
assert result["started_at"] == "2024-01-01T10:00:00"
|
||||
assert result["completed_at"] is None
|
||||
|
||||
|
||||
class TestWorkerStatus:
|
||||
"""Tests for WorkerStatus enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Has expected status values."""
|
||||
assert WorkerStatus.IDLE.value == "idle"
|
||||
assert WorkerStatus.WORKING.value == "working"
|
||||
assert WorkerStatus.COMPLETED.value == "completed"
|
||||
assert WorkerStatus.FAILED.value == "failed"
|
||||
|
||||
|
||||
# Legacy test functions for backwards compatibility
|
||||
def test_coordinator_initialization():
|
||||
"""Test coordinator can be initialized."""
|
||||
spec_dir = Path("/tmp/test-spec")
|
||||
project_dir = Path("/tmp/test-project")
|
||||
|
||||
coordinator = SwarmCoordinator(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
max_workers=2,
|
||||
)
|
||||
|
||||
assert coordinator.max_workers == 2
|
||||
assert coordinator.spec_dir == spec_dir
|
||||
assert coordinator.project_dir == project_dir
|
||||
assert len(coordinator.workers) == 0
|
||||
assert len(coordinator.claimed_files) == 0
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests."""
|
||||
print("\nTesting Multi-Agent Parallelism Coordinator")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
test_coordinator_initialization()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ All tests passed!")
|
||||
return 0
|
||||
|
||||
except AssertionError as e:
|
||||
print(f"\n✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_tests())
|
||||
+49
-19
@@ -22,10 +22,13 @@ from workspace import (
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
setup_workspace,
|
||||
STAGING_WORKTREE_NAME,
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestWorkspaceMode:
|
||||
"""Tests for WorkspaceMode enum."""
|
||||
@@ -121,11 +124,11 @@ class TestGetExistingBuildWorktree:
|
||||
|
||||
def test_existing_worktree(self, temp_git_repo: Path):
|
||||
"""Returns path when worktree exists."""
|
||||
# Create the worktree directory structure
|
||||
worktree_path = temp_git_repo / ".worktrees" / STAGING_WORKTREE_NAME
|
||||
# Create the worktree directory structure (per-spec architecture)
|
||||
worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = get_existing_build_worktree(temp_git_repo, "test-spec")
|
||||
result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
|
||||
assert result == worktree_path
|
||||
|
||||
|
||||
@@ -147,14 +150,15 @@ class TestSetupWorkspace:
|
||||
"""Isolated mode creates worktree and returns manager."""
|
||||
working_dir, manager = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
TEST_SPEC_NAME,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert working_dir != temp_git_repo
|
||||
assert manager is not None
|
||||
assert working_dir.exists()
|
||||
assert working_dir.name == STAGING_WORKTREE_NAME
|
||||
# Per-spec architecture: worktree is named after the spec
|
||||
assert working_dir.name == TEST_SPEC_NAME
|
||||
|
||||
def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates .worktrees directory."""
|
||||
@@ -170,11 +174,18 @@ class TestSetupWorkspace:
|
||||
class TestWorkspaceUtilities:
|
||||
"""Tests for workspace utility functions."""
|
||||
|
||||
def test_staging_worktree_name_constant(self):
|
||||
"""STAGING_WORKTREE_NAME is defined."""
|
||||
from workspace import STAGING_WORKTREE_NAME
|
||||
assert STAGING_WORKTREE_NAME is not None
|
||||
assert len(STAGING_WORKTREE_NAME) > 0
|
||||
def test_per_spec_worktree_naming(self, temp_git_repo: Path):
|
||||
"""Per-spec architecture uses spec name for worktree directory."""
|
||||
spec_name = "my-spec-001"
|
||||
working_dir, manager = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Worktree should be named after the spec
|
||||
assert working_dir.name == spec_name
|
||||
assert working_dir.parent.name == ".worktrees"
|
||||
|
||||
|
||||
class TestWorkspaceIntegration:
|
||||
@@ -325,23 +336,42 @@ class TestWorkspaceErrors:
|
||||
)
|
||||
|
||||
|
||||
class TestStagingWorktreeName:
|
||||
"""Tests for staging worktree naming."""
|
||||
class TestPerSpecWorktreeName:
|
||||
"""Tests for per-spec worktree naming (new architecture)."""
|
||||
|
||||
def test_staging_name_consistent(self, temp_git_repo: Path):
|
||||
"""Staging worktree name is consistent across setups."""
|
||||
from workspace import STAGING_WORKTREE_NAME
|
||||
def test_worktree_named_after_spec(self, temp_git_repo: Path):
|
||||
"""Worktree is named after the spec."""
|
||||
spec_name = "spec-1"
|
||||
working_dir, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Per-spec architecture: worktree directory matches spec name
|
||||
assert working_dir.name == spec_name
|
||||
|
||||
def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
|
||||
"""Different specs create separate worktrees."""
|
||||
working_dir1, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-1",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert working_dir1.name == STAGING_WORKTREE_NAME
|
||||
working_dir2, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-2",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
def test_staging_path_in_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Staging worktree is created in .worktrees directory."""
|
||||
# Each spec has its own worktree
|
||||
assert working_dir1.name == "spec-1"
|
||||
assert working_dir2.name == "spec-2"
|
||||
assert working_dir1 != working_dir2
|
||||
|
||||
def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Worktree is created in .worktrees directory."""
|
||||
working_dir, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
|
||||
Reference in New Issue
Block a user