diff --git a/.gitignore b/.gitignore index 1994a53..cd81520 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ Homestead.yaml Thumbs.db /docs /CLAUDE.md + +# SQLite test databases +planforge_test_* +planforge diff --git a/app/Actions/GenerateTasksFromTechSpec.php b/app/Actions/GenerateTasksFromTechSpec.php new file mode 100644 index 0000000..53d1a7a --- /dev/null +++ b/app/Actions/GenerateTasksFromTechSpec.php @@ -0,0 +1,71 @@ +id) + ->where('type', DocumentType::Tech) + ->with('currentVersion') + ->first(); + + if (! $techDoc?->currentVersion) { + throw new RuntimeException('No Tech Spec found. Generate a Tech Spec first.'); + } + + $prdDoc = Document::where('project_id', $project->id) + ->where('type', DocumentType::Prd) + ->with('currentVersion') + ->first(); + + $run = PlanRun::create([ + 'project_id' => $project->id, + 'triggered_by' => $userId, + 'status' => PlanRunStatus::Queued, + 'provider' => $project->preferred_provider ?? 'anthropic', + 'model' => $project->preferred_model ?? 'claude-sonnet-4-20250514', + 'input_snapshot' => [ + 'tech_version_id' => $techDoc->currentVersion->id, + 'prd_version_id' => $prdDoc?->currentVersion?->id, + ], + ]); + + $step = PlanRunStep::create([ + 'plan_run_id' => $run->id, + 'step' => StepType::Tasks, + 'status' => PlanRunStepStatus::Queued, + 'attempt' => 0, + 'provider' => $run->provider, + 'model' => $run->model, + ]); + + $taskSet = TaskSet::create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techDoc->currentVersion->id, + 'source_prd_version_id' => $prdDoc?->currentVersion?->id, + 'plan_run_id' => $run->id, + 'plan_run_step_id' => $step->id, + ]); + + GenerateTasksJob::dispatch($run->id, $taskSet->id); + + return $taskSet->load('planRunStep'); + }); + } +} diff --git a/app/Enums/TaskCategory.php b/app/Enums/TaskCategory.php new file mode 100644 index 0000000..496f25b --- /dev/null +++ b/app/Enums/TaskCategory.php @@ -0,0 +1,13 @@ +afterCommit(); + } + + public function uniqueId(): string + { + return $this->planRunId.':tasks'; + } + + public function middleware(): array + { + return [new RateLimited('llm:requests')]; + } + + public function handle(): void + { + $run = PlanRun::with('project')->findOrFail($this->planRunId); + $taskSet = TaskSet::findOrFail($this->taskSetId); + $step = PlanRunStep::where('plan_run_id', $run->id) + ->where('step', StepType::Tasks) + ->firstOrFail(); + + $step->update([ + 'status' => PlanRunStepStatus::Running, + 'attempt' => $step->attempt + 1, + 'started_at' => now(), + 'next_attempt_at' => null, + ]); + + try { + $techSpec = $taskSet->sourceTechVersion->content_md; + + $prdDoc = Document::where('project_id', $run->project_id) + ->where('type', DocumentType::Prd) + ->with('currentVersion') + ->first(); + + $prdSummary = $prdDoc?->currentVersion?->summary + ?? $this->truncate($prdDoc?->currentVersion?->content_md, 2000); + + $response = $this->callAI($run, $techSpec, $prdSummary); + + $this->storeRateLimits($step, $response); + $this->persistTasks($run->project_id, $taskSet, $response->structured); + + $step->update([ + 'status' => PlanRunStepStatus::Succeeded, + 'finished_at' => now(), + ]); + + $taskSet->update([ + 'meta' => ['task_count' => count($response->structured['tasks'] ?? [])], + ]); + + $run->update([ + 'status' => PlanRunStatus::Succeeded, + 'finished_at' => now(), + ]); + + } catch (PrismRateLimitedException $e) { + $this->handleRateLimit($e, $step); + } catch (Throwable $e) { + $this->handleError($e, $step, $run); + } + } + + private function callAI(PlanRun $run, string $techSpec, ?string $prdSummary) + { + $provider = $this->resolveProvider($run->provider); + + $builder = Prism::structured() + ->using($provider, $run->model) + ->withSchema(TasksSchema::make()) + ->withMaxTokens(8000) + ->withSystemPrompt(view('prompts.tasks.system')->render()) + ->withPrompt(view('prompts.tasks.user', [ + 'project' => $run->project, + 'techSpec' => $techSpec, + 'prdSummary' => $prdSummary, + ])->render()) + ->withClientOptions(['timeout' => 180]); + + if ($provider === Provider::OpenAI) { + $builder = $builder->withProviderOptions(['schema' => ['strict' => true]]); + } + + return $builder->asStructured(); + } + + private function persistTasks(string $projectId, TaskSet $taskSet, array $structured): void + { + DB::transaction(function () use ($projectId, $taskSet, $structured) { + // Soft delete previous AI-generated tasks + Task::where('project_id', $projectId) + ->whereNotNull('task_set_id') + ->delete(); + + $tasks = $structured['tasks'] ?? []; + $tempIdMap = []; + + // Generate lexicographic positions for proper Flowforge ordering + $currentRank = Rank::forEmptySequence(); + + foreach ($tasks as $data) { + $task = Task::create([ + 'project_id' => $projectId, + 'task_set_id' => $taskSet->id, + 'plan_run_id' => $taskSet->plan_run_id, + 'plan_run_step_id' => $taskSet->plan_run_step_id, + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'category' => $data['category'] ?? null, + 'priority' => $data['priority'] ?? 'med', + 'status' => $data['status'] ?? 'todo', + 'estimate' => $data['estimate'] ?? null, + 'acceptance_criteria' => $data['acceptance_criteria'] ?? [], + 'source_refs' => $data['source_refs'] ?? [], + 'labels' => $data['labels'] ?? [], + 'depends_on' => [], + 'position' => $currentRank->get(), + ]); + + // Generate next position + $currentRank = Rank::after($currentRank); + + if (isset($data['temp_id'])) { + $tempIdMap[$data['temp_id']] = $task->id; + } + } + + // Resolve dependencies + foreach ($tasks as $data) { + if (empty($data['depends_on']) || ! isset($data['temp_id'])) { + continue; + } + + $taskId = $tempIdMap[$data['temp_id']] ?? null; + if (! $taskId) { + continue; + } + + $resolvedDeps = collect($data['depends_on']) + ->map(fn ($tempId) => $tempIdMap[$tempId] ?? null) + ->filter() + ->values() + ->toArray(); + + Task::where('id', $taskId)->update(['depends_on' => $resolvedDeps]); + } + }); + } + + private function storeRateLimits(PlanRunStep $step, $response): void + { + if (! $response->meta->rateLimits) { + return; + } + + $step->update([ + 'rate_limits' => collect($response->meta->rateLimits)->map(fn ($rl) => [ + 'name' => $rl->name, + 'limit' => $rl->limit, + 'remaining' => $rl->remaining, + 'resetsAt' => $rl->resetsAt?->toIso8601String(), + ])->toArray(), + ]); + } + + private function handleRateLimit(PrismRateLimitedException $e, PlanRunStep $step): void + { + $resetAt = collect($e->rateLimits) + ->map(fn ($rl) => $rl->resetsAt) + ->filter() + ->sort() + ->first(); + + $delaySeconds = $resetAt + ? max(5, now()->diffInSeconds($resetAt, false) + 5) + : 60; + + $step->update([ + 'status' => PlanRunStepStatus::Delayed, + 'next_attempt_at' => now()->addSeconds($delaySeconds), + 'rate_limits' => collect($e->rateLimits)->map(fn ($rl) => [ + 'name' => $rl->name, + 'limit' => $rl->limit, + 'remaining' => $rl->remaining, + 'resetsAt' => $rl->resetsAt?->toIso8601String(), + ])->toArray(), + ]); + + $this->release($delaySeconds); + } + + private function handleError(Throwable $e, PlanRunStep $step, PlanRun $run): void + { + $isTransient = str_contains(strtolower($e->getMessage()), 'overload') + || str_contains(strtolower($e->getMessage()), 'capacity') + || str_contains(strtolower($e->getMessage()), 'temporarily'); + + if ($isTransient && $this->attempts() < $this->tries) { + $delaySeconds = $this->backoff[$this->attempts() - 1] ?? 300; + + $step->update([ + 'status' => PlanRunStepStatus::Delayed, + 'next_attempt_at' => now()->addSeconds($delaySeconds), + 'error_message' => $e->getMessage(), + ]); + + $this->release($delaySeconds); + + return; + } + + $step->update([ + 'status' => PlanRunStepStatus::Failed, + 'error_message' => $e->getMessage(), + 'finished_at' => now(), + ]); + + $run->update([ + 'status' => PlanRunStatus::Failed, + 'error_message' => $e->getMessage(), + 'finished_at' => now(), + ]); + + throw $e; + } + + private function resolveProvider(string $provider): Provider + { + return match ($provider) { + 'anthropic' => Provider::Anthropic, + 'openai' => Provider::OpenAI, + 'gemini' => Provider::Gemini, + 'mistral' => Provider::Mistral, + 'groq' => Provider::Groq, + default => Provider::Anthropic, + }; + } + + private function truncate(?string $text, int $length): ?string + { + if ($text === null) { + return null; + } + + return strlen($text) > $length ? substr($text, 0, $length).'...' : $text; + } +} diff --git a/app/Livewire/Projects/Tabs/KanbanBoard.php b/app/Livewire/Projects/Tabs/KanbanBoard.php index ee72741..168dcea 100644 --- a/app/Livewire/Projects/Tabs/KanbanBoard.php +++ b/app/Livewire/Projects/Tabs/KanbanBoard.php @@ -2,7 +2,11 @@ namespace App\Livewire\Projects\Tabs; +use App\Actions\GenerateTasksFromTechSpec; +use App\Enums\PlanRunStepStatus; +use App\Models\Project; use App\Models\Task; +use App\Models\TaskSet; use Filament\Actions\Concerns\InteractsWithActions; use Filament\Actions\Contracts\HasActions; use Filament\Actions\CreateAction; @@ -15,6 +19,7 @@ use Filament\Forms\Contracts\HasForms; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Schema; +use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; use Relaticle\Flowforge\Board; @@ -50,18 +55,43 @@ public function board(Board $board): Board Column::make('doing')->label('In Progress')->color('info'), Column::make('done')->label('Done')->color('success'), ]) - ->cardSchema(fn (Schema $schema) => $schema->components([ - TextEntry::make('title') - ->weight('bold') - ->size('sm'), - TextEntry::make('description') - ->limit(60) - ->color('gray'), - TextEntry::make('estimate') - ->badge() - ->color('primary') - ->visible(fn ($record) => filled($record->estimate)), - ])) + ->cardSchema(fn (Schema $schema) => $schema + ->extraAttributes(['class' => 'space-y-2']) + ->components([ + TextEntry::make('description') + ->hiddenLabel() + ->limit(100) + ->color('gray') + ->size('sm'), + TextEntry::make('badges') + ->hiddenLabel() + ->state(fn ($record) => collect([ + $record->category ? [ + 'label' => match ($record->category?->value ?? $record->category) { + 'backend' => 'Backend', + 'frontend' => 'Frontend', + 'db' => 'DB', + 'infra' => 'Infra', + 'tests' => 'Tests', + 'docs' => 'Docs', + default => $record->category, + }, + 'color' => match ($record->category?->value ?? $record->category) { + 'backend' => 'success', + 'frontend' => 'info', + 'db' => 'warning', + 'tests' => 'primary', + default => 'gray', + }, + ] : null, + $record->estimate ? [ + 'label' => $record->estimate, + 'color' => 'gray', + 'icon' => true, + ] : null, + ])->filter()->values()->toArray()) + ->view('components.task-badges'), + ])) ->columnActions([ CreateAction::make() ->label('Add task') @@ -121,9 +151,58 @@ public function board(Board $board): Board #[On('tasksUpdated')] #[On('planRunCompleted')] + #[On('taskGenerationStarted')] public function refreshBoard(): void { - // Flowforge handles refresh automatically via Livewire reactivity + unset($this->latestTaskSet); + unset($this->isStale); + unset($this->isGeneratingTasks); + } + + #[Computed] + public function latestTaskSet(): ?TaskSet + { + return TaskSet::where('project_id', $this->projectId) + ->with(['planRunStep', 'sourceTechVersion']) + ->latest() + ->first(); + } + + #[Computed] + public function isStale(): bool + { + return $this->latestTaskSet?->isStale() ?? false; + } + + #[Computed] + public function isGeneratingTasks(): bool + { + $taskSet = $this->latestTaskSet; + + if (! $taskSet) { + return false; + } + + return in_array($taskSet->status, [ + PlanRunStepStatus::Queued, + PlanRunStepStatus::Running, + PlanRunStepStatus::Delayed, + ]); + } + + public function regenerateTasks(): void + { + if ($this->isGeneratingTasks) { + return; + } + + $project = Project::findOrFail($this->projectId); + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + + unset($this->latestTaskSet); + unset($this->isStale); + unset($this->isGeneratingTasks); } public function render() diff --git a/app/Livewire/Projects/Tabs/Tech.php b/app/Livewire/Projects/Tabs/Tech.php index 22993d4..0596f61 100644 --- a/app/Livewire/Projects/Tabs/Tech.php +++ b/app/Livewire/Projects/Tabs/Tech.php @@ -2,9 +2,13 @@ namespace App\Livewire\Projects\Tabs; +use App\Actions\GenerateTasksFromTechSpec; use App\Enums\DocumentType; +use App\Enums\PlanRunStepStatus; use App\Models\Document; use App\Models\DocumentVersion; +use App\Models\Project; +use App\Models\TaskSet; use Livewire\Attributes\Computed; use Livewire\Attributes\On; use Livewire\Component; @@ -83,6 +87,54 @@ public function handlePlanRunCompleted(): void $this->loadContent(); } + #[Computed] + public function latestTaskSet(): ?TaskSet + { + return TaskSet::where('project_id', $this->projectId) + ->with('planRunStep') + ->latest() + ->first(); + } + + #[Computed] + public function isGeneratingTasks(): bool + { + $taskSet = $this->latestTaskSet; + + if (! $taskSet) { + return false; + } + + return in_array($taskSet->status, [ + PlanRunStepStatus::Queued, + PlanRunStepStatus::Running, + PlanRunStepStatus::Delayed, + ]); + } + + public function generateTasks(): void + { + if ($this->isGeneratingTasks) { + return; + } + + $project = Project::findOrFail($this->projectId); + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + + unset($this->latestTaskSet); + unset($this->isGeneratingTasks); + + $this->dispatch('taskGenerationStarted'); + } + + #[On('taskGenerationCompleted')] + public function handleTaskGenerationCompleted(): void + { + unset($this->latestTaskSet); + unset($this->isGeneratingTasks); + } + public function render() { return view('livewire.projects.tabs.tech'); diff --git a/app/Models/Project.php b/app/Models/Project.php index 7e0e455..acf9cc7 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -56,4 +56,14 @@ public function tasks(): HasMany { return $this->hasMany(Task::class); } + + public function taskSets(): HasMany + { + return $this->hasMany(TaskSet::class); + } + + public function latestTaskSet(): ?TaskSet + { + return $this->taskSets()->latest()->first(); + } } diff --git a/app/Models/Task.php b/app/Models/Task.php index be9578e..37caf07 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Enums\TaskCategory; +use App\Enums\TaskPriority; use App\Enums\TaskStatus; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -19,13 +21,17 @@ class Task extends Model 'story_id', 'plan_run_id', 'plan_run_step_id', + 'task_set_id', 'title', 'description', 'acceptance_criteria', 'estimate', 'labels', 'depends_on', + 'source_refs', 'status', + 'category', + 'priority', 'position', ]; @@ -35,7 +41,10 @@ protected function casts(): array 'acceptance_criteria' => 'array', 'labels' => 'array', 'depends_on' => 'array', + 'source_refs' => 'array', 'status' => TaskStatus::class, + 'category' => TaskCategory::class, + 'priority' => TaskPriority::class, ]; } @@ -63,4 +72,9 @@ public function planRunStep(): BelongsTo { return $this->belongsTo(PlanRunStep::class); } + + public function taskSet(): BelongsTo + { + return $this->belongsTo(TaskSet::class); + } } diff --git a/app/Models/TaskSet.php b/app/Models/TaskSet.php new file mode 100644 index 0000000..e111266 --- /dev/null +++ b/app/Models/TaskSet.php @@ -0,0 +1,84 @@ + 'array', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function sourceTechVersion(): BelongsTo + { + return $this->belongsTo(DocumentVersion::class, 'source_tech_version_id'); + } + + public function sourcePrdVersion(): BelongsTo + { + return $this->belongsTo(DocumentVersion::class, 'source_prd_version_id'); + } + + public function planRun(): BelongsTo + { + return $this->belongsTo(PlanRun::class); + } + + public function planRunStep(): BelongsTo + { + return $this->belongsTo(PlanRunStep::class); + } + + public function tasks(): HasMany + { + return $this->hasMany(Task::class); + } + + /** + * Get status from the associated plan run step. + */ + public function getStatusAttribute(): ?PlanRunStepStatus + { + return $this->planRunStep?->status; + } + + /** + * Check if tasks are stale (tech spec has been updated since generation). + */ + public function isStale(): bool + { + $latestTechVersion = $this->project->documents() + ->where('type', DocumentType::Tech) + ->first() + ?->currentVersion; + + return $latestTechVersion + && $this->source_tech_version_id !== $latestTechVersion->id; + } +} diff --git a/app/Schemas/TasksSchema.php b/app/Schemas/TasksSchema.php new file mode 100644 index 0000000..5ca9f02 --- /dev/null +++ b/app/Schemas/TasksSchema.php @@ -0,0 +1,89 @@ + + */ +class TaskSetFactory extends Factory +{ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'source_tech_version_id' => DocumentVersion::factory(), + 'source_prd_version_id' => null, + 'plan_run_id' => null, + 'plan_run_step_id' => null, + 'meta' => null, + ]; + } + + public function withPrdVersion(): static + { + return $this->state(fn (array $attributes) => [ + 'source_prd_version_id' => DocumentVersion::factory(), + ]); + } + + public function withMeta(array $meta): static + { + return $this->state(fn (array $attributes) => [ + 'meta' => $meta, + ]); + } +} diff --git a/database/migrations/2025_12_31_052124_create_task_sets_table.php b/database/migrations/2025_12_31_052124_create_task_sets_table.php new file mode 100644 index 0000000..c0b7f2a --- /dev/null +++ b/database/migrations/2025_12_31_052124_create_task_sets_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->foreignUlid('project_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_tech_version_id')->constrained('document_versions')->cascadeOnDelete(); + $table->foreignUlid('source_prd_version_id')->nullable()->constrained('document_versions')->nullOnDelete(); + $table->foreignUlid('plan_run_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('plan_run_step_id')->nullable()->constrained()->nullOnDelete(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['project_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('task_sets'); + } +}; diff --git a/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php b/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php new file mode 100644 index 0000000..648518c --- /dev/null +++ b/database/migrations/2025_12_31_052202_add_task_generation_fields_to_tasks_table.php @@ -0,0 +1,33 @@ +foreignUlid('task_set_id')->nullable()->after('plan_run_step_id')->constrained()->cascadeOnDelete(); + $table->string('category')->nullable()->after('status'); + $table->string('priority')->default('med')->after('category'); + $table->json('source_refs')->nullable()->after('depends_on'); + + $table->index('task_set_id'); + $table->index('category'); + $table->index('priority'); + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['task_set_id']); + $table->dropIndex(['task_set_id']); + $table->dropIndex(['category']); + $table->dropIndex(['priority']); + $table->dropColumn(['task_set_id', 'category', 'priority', 'source_refs']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ddfa8e5..99e900b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ use App\Models\Task; use App\Models\User; use Illuminate\Database\Seeder; +use Relaticle\Flowforge\Services\Rank; class DatabaseSeeder extends Seeder { @@ -113,6 +114,11 @@ public function run(): void 'sort_order' => 0, ]); + // Generate lexicographic positions for Flowforge + $rank1 = Rank::forEmptySequence(); + $rank2 = Rank::after($rank1); + $rank3 = Rank::after($rank2); + Task::create([ 'project_id' => $project->id, 'epic_id' => $epic->id, @@ -124,7 +130,7 @@ public function run(): void 'estimate' => '2h', 'labels' => ['backend', 'database'], 'status' => TaskStatus::Done, - 'board_order' => 0, + 'position' => $rank1->get(), ]); Task::create([ @@ -138,7 +144,7 @@ public function run(): void 'estimate' => '3h', 'labels' => ['frontend', 'livewire'], 'status' => TaskStatus::Doing, - 'board_order' => 1, + 'position' => $rank2->get(), ]); Task::create([ @@ -149,7 +155,7 @@ public function run(): void 'estimate' => '4h', 'labels' => ['frontend', 'ux'], 'status' => TaskStatus::Todo, - 'board_order' => 2, + 'position' => $rank3->get(), ]); } } diff --git a/resources/views/components/task-badges.blade.php b/resources/views/components/task-badges.blade.php new file mode 100644 index 0000000..a03182e --- /dev/null +++ b/resources/views/components/task-badges.blade.php @@ -0,0 +1,26 @@ +@php + $badges = $getState() ?? []; +@endphp + +@if(count($badges) > 0) +
+ @foreach($badges as $badge) + ($badge['color'] ?? 'gray') === 'gray', + 'bg-green-100 text-green-700' => ($badge['color'] ?? '') === 'success', + 'bg-blue-100 text-blue-700' => ($badge['color'] ?? '') === 'info', + 'bg-amber-100 text-amber-700' => ($badge['color'] ?? '') === 'warning', + 'bg-indigo-100 text-indigo-700' => ($badge['color'] ?? '') === 'primary', + 'bg-red-100 text-red-700' => ($badge['color'] ?? '') === 'danger', + ])> + @if($badge['icon'] ?? false) + + + + @endif + {{ $badge['label'] }} + + @endforeach +
+@endif diff --git a/resources/views/livewire/projects/tabs/kanban-board.blade.php b/resources/views/livewire/projects/tabs/kanban-board.blade.php index fa9012b..9476853 100644 --- a/resources/views/livewire/projects/tabs/kanban-board.blade.php +++ b/resources/views/livewire/projects/tabs/kanban-board.blade.php @@ -2,7 +2,57 @@

Kanban Board

-

Drag and drop tasks across stages

+
+

Drag and drop tasks across stages

+ @if($this->latestTaskSet) + + Generated from Tech Spec v{{ $this->latestTaskSet->sourceTechVersion?->id ? substr($this->latestTaskSet->sourceTechVersion->id, 0, 8) : 'N/A' }} + + @endif +
+
+
+ @if($this->isStale) +
+ + + + Tasks may be stale +
+ @endif + @if($this->latestTaskSet || $this->isStale) + + @endif
diff --git a/resources/views/livewire/projects/tabs/tech.blade.php b/resources/views/livewire/projects/tabs/tech.blade.php index d88e66a..d116972 100644 --- a/resources/views/livewire/projects/tabs/tech.blade.php +++ b/resources/views/livewire/projects/tabs/tech.blade.php @@ -19,6 +19,39 @@ > Save + @if($this->document) + + @endif diff --git a/resources/views/prompts/tasks/system.blade.php b/resources/views/prompts/tasks/system.blade.php new file mode 100644 index 0000000..61aea53 --- /dev/null +++ b/resources/views/prompts/tasks/system.blade.php @@ -0,0 +1,21 @@ +You are a senior staff engineer breaking down a technical specification into actionable implementation tasks. + +## Requirements + +1. Every task MUST include `source_refs` pointing to Tech Spec sections +2. Tasks should be implementation-ready with clear acceptance criteria +3. Prefer small tasks (2-8 hours) over large vague tasks +4. Each acceptance criterion must be testable +5. Only specify dependencies when truly necessary + +## Task Guidelines + +- Title: Start with a verb (Implement, Create, Add, Configure, Write) +- Description: Include context and edge cases +- Source Refs: Format as "Section Name > Subsection" or "DB: table_name" + +## Priority + +- **high**: Blocking tasks, core functionality +- **med**: Standard implementation work +- **low**: Nice-to-haves, documentation diff --git a/resources/views/prompts/tasks/user.blade.php b/resources/views/prompts/tasks/user.blade.php new file mode 100644 index 0000000..2d23206 --- /dev/null +++ b/resources/views/prompts/tasks/user.blade.php @@ -0,0 +1,27 @@ +## Project: {{ $project->name }} + +## Project Idea +{{ $project->idea }} + +## Constraints +@if($project->constraints) +@foreach($project->constraints as $key => $value) +- **{{ $key }}**: {{ is_array($value) ? implode(', ', $value) : $value }} +@endforeach +@else +No specific constraints. +@endif + +## Tech Spec +{{ $techSpec }} + +@if($prdSummary) +## PRD Context +{{ $prdSummary }} +@endif + +--- + +Generate implementation tasks covering ALL sections of the Tech Spec. +Order logically: migrations → models → controllers → views → tests. +Every task must reference which Tech Spec section it implements. diff --git a/resources/views/vendor/flowforge/components/card-flex.blade.php b/resources/views/vendor/flowforge/components/card-flex.blade.php new file mode 100644 index 0000000..ba41b81 --- /dev/null +++ b/resources/views/vendor/flowforge/components/card-flex.blade.php @@ -0,0 +1,85 @@ +@php + use Filament\Actions\Action; + use Filament\Actions\ActionGroup; + use Filament\Schemas\Components\Component; + + $gap = $getGap(); + $wrap = $shouldWrap(); + $justify = $getJustify(); + $align = $getAlign(); +@endphp + +
merge($getExtraAttributes(), escape: false) + ->class([ + 'flex', + // Gap classes optimized for cards + 'gap-1' => $gap === 'xs', + 'gap-2' => $gap === 'sm', + 'gap-3' => $gap === 'md', + 'gap-4' => $gap === 'lg', + // Wrap settings + 'flex-wrap' => $wrap, + 'flex-nowrap' => !$wrap, + // Justify settings + 'justify-start' => $justify === 'start', + 'justify-center' => $justify === 'center', + 'justify-end' => $justify === 'end', + 'justify-between' => $justify === 'between', + 'justify-around' => $justify === 'around', + 'justify-evenly' => $justify === 'evenly', + // Align settings + 'items-start' => $align === 'start', + 'items-center' => $align === 'center', + 'items-end' => $align === 'end', + 'items-baseline' => $align === 'baseline', + 'items-stretch' => $align === 'stretch', + ]) + }} +> + @foreach ($getChildSchema()->getComponents() as $component) + @if (($component instanceof Action) || ($component instanceof ActionGroup)) +
+ {{ $component }} +
+ @else + @php + $hiddenJs = $component->getHiddenJs(); + $visibleJs = $component->getVisibleJs(); + $componentStatePath = $component->getStatePath(); + @endphp + +
getAfterStateUpdatedJs()) + x-init="{!! implode(';', array_map( + fn (string $js): string => '$wire.watch(' . Js::from($componentStatePath) . ', ($state, $old) => ($state !== undefined) && eval(' . Js::from($js) . '))', + $afterStateUpdatedJs, + )) !!}" + @endif + @if (filled($visibilityJs = match ([filled($hiddenJs), filled($visibleJs)]) { + [true, true] => "(! ({$hiddenJs})) && ({$visibleJs})", + [true, false] => "! ({$hiddenJs})", + [false, true] => $visibleJs, + default => null, + })) + x-bind:class="{ 'fi-hidden': ! ({!! $visibilityJs !!}) }" + x-cloak + @endif + @class([ + 'flex-shrink-0' => !($component instanceof Component && $component->canGrow()), + 'flex-grow' => ($component instanceof Component) && $component->canGrow(), + ]) + > + {{ $component }} +
+ @endif + @endforeach +
diff --git a/resources/views/vendor/flowforge/components/filters.blade.php b/resources/views/vendor/flowforge/components/filters.blade.php new file mode 100644 index 0000000..4bf7f6c --- /dev/null +++ b/resources/views/vendor/flowforge/components/filters.blade.php @@ -0,0 +1,131 @@ +@php + use Filament\Support\Enums\IconSize;use Filament\Support\Icons\Heroicon;use Filament\Tables\Filters\Indicator;use Filament\Tables\View\TablesIconAlias;use Illuminate\View\ComponentAttributeBag; + use Filament\Support\Facades\FilamentView; + use Filament\Tables\View\TablesRenderHook; + + use function Filament\Support\generate_icon_html;use function Filament\Support\prepare_inherited_attributes; + $table = $this->getTable(); + $isFilterable = $table->isFilterable(); + $isFiltered = $table->isFiltered(); + $isSearchable = $table->isSearchable(); + $filterIndicators = $table->getFilterIndicators(); +@endphp + + +
+
+ @if($isFilterable) + + + {{ $table->getFiltersTriggerAction()->badge($table->getActiveFiltersCount()) }} + + +
+
+

+ {{ __('filament-tables::table.filters.heading') }} +

+ +
+ + {{ __('filament-tables::table.filters.actions.reset.label') }} + +
+
+ + {{ $this->getTableFiltersForm() }} + + + @if ($table->getFiltersApplyAction()->isVisible()) +
+ {{ $table->getFiltersApplyAction() }} +
+ @endif +
+ +
+ @endif + + @if($isSearchable) + {{-- Search input --}} + + @endif +
+ + @if ($filterIndicators) + @if (filled($filterIndicatorsView = FilamentView::renderHook(TablesRenderHook::FILTER_INDICATORS, scopes: static::class, data: ['filterIndicators' => $filterIndicators]))) + {{ $filterIndicatorsView }} + @else +
+
+ + {{ __('filament-tables::table.filters.indicator') }} + + +
+ @foreach ($filterIndicators as $indicator) + @php + $indicatorColor = $indicator->getColor(); + @endphp + + + {{ $indicator->getLabel() }} + + @if ($indicator->isRemovable()) + @php + $indicatorRemoveLivewireClickHandler = $indicator->getRemoveLivewireClickHandler(); + @endphp + + + @endif + + @endforeach +
+
+ + @if (collect($filterIndicators)->contains(fn (Indicator $indicator): bool => $indicator->isRemovable())) + + @endif +
+ @endif + @endif +
diff --git a/resources/views/vendor/flowforge/filament/pages/board-page.blade.php b/resources/views/vendor/flowforge/filament/pages/board-page.blade.php new file mode 100644 index 0000000..b35aefc --- /dev/null +++ b/resources/views/vendor/flowforge/filament/pages/board-page.blade.php @@ -0,0 +1,5 @@ + +
+ {{ $this->board }} +
+
diff --git a/resources/views/vendor/flowforge/index.blade.php b/resources/views/vendor/flowforge/index.blade.php new file mode 100644 index 0000000..f48427f --- /dev/null +++ b/resources/views/vendor/flowforge/index.blade.php @@ -0,0 +1,36 @@ +@php use Filament\Support\Facades\FilamentAsset; @endphp +@props(['columns', 'config']) + +
+ + @include('flowforge::components.filters') + + +
+
+ @foreach($columns as $columnId => $column) + + @endforeach +
+
+ + +
diff --git a/resources/views/vendor/flowforge/livewire/card.blade.php b/resources/views/vendor/flowforge/livewire/card.blade.php new file mode 100644 index 0000000..a6f646f --- /dev/null +++ b/resources/views/vendor/flowforge/livewire/card.blade.php @@ -0,0 +1,67 @@ +@props(['columnId', 'record']) + +@php + $processedRecordActions = $this->getBoard()->getBoardRecordActions($record); + $hasActions = !empty($processedRecordActions); + $cardAction = $this->getBoard()->getCardAction(); + $hasCardAction = $cardAction !== null; + $hasPositionIdentifier = $this->getBoard()->getPositionIdentifierAttribute() !== null; + + // Get priority for border color + $model = $record['model'] ?? null; + $priority = $model?->priority?->value ?? $model?->priority ?? null; + $priorityBorderClass = match($priority) { + 'high' => 'border-l-4 border-l-red-500', + 'med' => 'border-l-4 border-l-amber-400', + 'low' => 'border-l-4 border-l-gray-300', + default => '', + }; +@endphp + +
$hasActions || $hasCardAction, + 'cursor-pointer transition-all duration-100 ease-in-out hover:shadow-lg hover:border-gray-400 active:shadow-md' => $hasCardAction, + 'cursor-grab hover:cursor-grabbing' => $hasPositionIdentifier, + 'cursor-default' => !$hasActions && !$hasCardAction && !$hasPositionIdentifier, + ]) + @if($hasPositionIdentifier) + x-sortable-handle + x-sortable-item="{{ $record['id'] }}" + @endif + data-card-id="{{ $record['id'] }}" + data-position="{{ $record['position'] ?? '' }}" +> +
+
+

+ {{ $record['title'] }} +

+ + @if($hasActions) +
+ +
+ @endif +
+ +
+ {{-- Render card schema with compact spacing --}} + @if(filled($record['schema'])) + {{ $record['schema'] }} + @endif +
+
+
diff --git a/resources/views/vendor/flowforge/livewire/column.blade.php b/resources/views/vendor/flowforge/livewire/column.blade.php new file mode 100644 index 0000000..7015e23 --- /dev/null +++ b/resources/views/vendor/flowforge/livewire/column.blade.php @@ -0,0 +1,122 @@ +@props(['columnId', 'column', 'config']) + +@php + use Relaticle\Flowforge\Support\ColorResolver; + + // Resolve the color once using our centralized resolver + $resolvedColor = ColorResolver::resolve($column['color']); + $isSemantic = ColorResolver::isSemantic($resolvedColor); + + // For non-semantic colors, get the color array + $colorShades = $isSemantic ? null : $resolvedColor; +@endphp + +
+ +
+
+ @if ($column['icon'] ?? null) + + @endif +

+ {{ $column['label'] }} +

+ + {{-- Count Badge --}} + @if($isSemantic) + {{-- Use native Filament badge for semantic colors --}} + + {{ $column['total'] ?? (isset($column['items']) ? count($column['items']) : 0) }} + + @elseif($colorShades) + {{-- Custom badge for Color arrays --}} +
+ {{ $column['total'] ?? (isset($column['items']) ? count($column['items']) : 0) }} +
+ @else + {{-- Fallback: simple gray badge if no color --}} +
+ {{ $column['total'] ?? (isset($column['items']) ? count($column['items']) : 0) }} +
+ @endif +
+ + + {{-- Column actions are always visible --}} + @php + $processedActions = $this->getBoardColumnActions($columnId); + @endphp + + @if(count($processedActions) > 0) +
+ @if(count($processedActions) === 1) + {{ $processedActions[0] }} + @else + + @endif +
+ @endif +
+ + +
getBoard()->getPositionIdentifierAttribute()) + x-sortable + x-sortable-group="cards" + @end.stop="handleSortableEnd($event)" + @endif + @if(isset($column['total']) && $column['total'] > count($column['items'])) + @scroll.throttle.100ms="handleColumnScroll($event, '{{ $columnId }}')" + @endif + class="flowforge-column-content p-3 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain kanban-cards" + style="max-height: calc(100vh - 13rem);" + > + @if (isset($column['items']) && count($column['items']) > 0) + @foreach ($column['items'] as $record) + + @endforeach + + {{-- Always show status message at bottom --}} +
+ @if(isset($column['total']) && $column['total'] > count($column['items'])) + {{-- More items available --}} +
+ +
+ {{ __('flowforge::flowforge.loading_more_cards') }} +
+
+ @endif +
+ @else + + @endif +
+
\ No newline at end of file diff --git a/resources/views/vendor/flowforge/livewire/empty-column.blade.php b/resources/views/vendor/flowforge/livewire/empty-column.blade.php new file mode 100644 index 0000000..ac5009c --- /dev/null +++ b/resources/views/vendor/flowforge/livewire/empty-column.blade.php @@ -0,0 +1,11 @@ +@props(['pluralCardLabel']) + +
+ +

+ {{ __('flowforge::flowforge.no_cards_in_column', ['cardLabel' => $pluralCardLabel]) }} +

+
diff --git a/tests/Feature/TaskGenerationTest.php b/tests/Feature/TaskGenerationTest.php new file mode 100644 index 0000000..fbf4cb3 --- /dev/null +++ b/tests/Feature/TaskGenerationTest.php @@ -0,0 +1,263 @@ +create(); + $project = Project::factory()->for($user)->create(); + + // Create tech spec document with version + $techDoc = Document::factory()->tech()->for($project)->create(); + $techVersion = DocumentVersion::factory() + ->for($techDoc, 'document') + ->withContent('# Tech Spec\n\n## API Endpoints\n\nPOST /projects') + ->create(); + $techDoc->update(['current_version_id' => $techVersion->id]); + + $action = new GenerateTasksFromTechSpec; + $taskSet = $action->handle($project); + + expect($taskSet)->toBeInstanceOf(TaskSet::class); + expect($taskSet->source_tech_version_id)->toBe($techVersion->id); + expect($taskSet->project_id)->toBe($project->id); + expect($taskSet->status)->toBe(PlanRunStepStatus::Queued); + + Queue::assertPushed(GenerateTasksJob::class, function ($job) use ($taskSet) { + return $job->taskSetId === $taskSet->id; + }); + }); + + it('throws exception when no tech spec exists', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + })->throws(RuntimeException::class, 'No Tech Spec found'); + + it('throws exception when tech spec has no current version', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + // Create tech doc but no version + Document::factory()->tech()->for($project)->create(); + + $action = new GenerateTasksFromTechSpec; + $action->handle($project); + })->throws(RuntimeException::class, 'No Tech Spec found'); + + it('includes prd version when available', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + // Create PRD + $prdDoc = Document::factory()->prd()->for($project)->create(); + $prdVersion = DocumentVersion::factory() + ->for($prdDoc, 'document') + ->withContent('# PRD Content') + ->create(); + $prdDoc->update(['current_version_id' => $prdVersion->id]); + + // Create Tech Spec + $techDoc = Document::factory()->tech()->for($project)->create(); + $techVersion = DocumentVersion::factory() + ->for($techDoc, 'document') + ->withContent('# Tech Spec') + ->create(); + $techDoc->update(['current_version_id' => $techVersion->id]); + + $action = new GenerateTasksFromTechSpec; + $taskSet = $action->handle($project); + + expect($taskSet->source_tech_version_id)->toBe($techVersion->id); + expect($taskSet->source_prd_version_id)->toBe($prdVersion->id); + }); + + it('creates plan run and step for tracking', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techDoc = Document::factory()->tech()->for($project)->create(); + $techVersion = DocumentVersion::factory() + ->for($techDoc, 'document') + ->create(); + $techDoc->update(['current_version_id' => $techVersion->id]); + + $action = new GenerateTasksFromTechSpec; + $taskSet = $action->handle($project); + + expect($taskSet->plan_run_id)->not->toBeNull(); + expect($taskSet->plan_run_step_id)->not->toBeNull(); + + $this->assertDatabaseHas('plan_runs', [ + 'id' => $taskSet->plan_run_id, + 'project_id' => $project->id, + 'status' => 'queued', + ]); + + $this->assertDatabaseHas('plan_run_steps', [ + 'id' => $taskSet->plan_run_step_id, + 'plan_run_id' => $taskSet->plan_run_id, + 'step' => 'tasks', + 'status' => 'queued', + ]); + }); +}); + +describe('TaskSet stale detection', function () { + it('returns false when task set matches current tech version', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techDoc = Document::factory()->tech()->for($project)->create(); + $techVersion = DocumentVersion::factory() + ->for($techDoc, 'document') + ->withContent('# Tech Spec v1') + ->create(); + $techDoc->update(['current_version_id' => $techVersion->id]); + + $taskSet = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techVersion->id, + ]); + + expect($taskSet->isStale())->toBeFalse(); + }); + + it('returns true when tech spec has been updated', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techDoc = Document::factory()->tech()->for($project)->create(); + + // Create v1 + $v1 = DocumentVersion::factory() + ->for($techDoc, 'document') + ->withContent('# Tech Spec v1') + ->create(); + $techDoc->update(['current_version_id' => $v1->id]); + + // Create task set from v1 + $taskSet = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $v1->id, + ]); + + expect($taskSet->isStale())->toBeFalse(); + + // Create v2 and update current + $v2 = DocumentVersion::factory() + ->for($techDoc, 'document') + ->withContent('# Tech Spec v2 - Updated') + ->create(); + $techDoc->update(['current_version_id' => $v2->id]); + + // Refresh and check stale + expect($taskSet->fresh()->isStale())->toBeTrue(); + }); + + it('returns false when no tech document exists', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + // Create task set without actual tech doc + $orphanVersion = DocumentVersion::factory()->create(); + $taskSet = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $orphanVersion->id, + ]); + + expect($taskSet->isStale())->toBeFalse(); + }); +}); + +describe('TaskSet status from step', function () { + it('returns status from associated plan run step', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techDoc = Document::factory()->tech()->for($project)->create(); + $techVersion = DocumentVersion::factory() + ->for($techDoc, 'document') + ->create(); + $techDoc->update(['current_version_id' => $techVersion->id]); + + $action = new GenerateTasksFromTechSpec; + $taskSet = $action->handle($project); + + expect($taskSet->status)->toBe(PlanRunStepStatus::Queued); + + // Simulate step status change + $taskSet->planRunStep->update(['status' => PlanRunStepStatus::Running]); + expect($taskSet->fresh()->status)->toBe(PlanRunStepStatus::Running); + + $taskSet->planRunStep->update(['status' => PlanRunStepStatus::Succeeded]); + expect($taskSet->fresh()->status)->toBe(PlanRunStepStatus::Succeeded); + }); + + it('returns null when no step associated', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techVersion = DocumentVersion::factory()->create(); + $taskSet = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techVersion->id, + 'plan_run_step_id' => null, + ]); + + expect($taskSet->status)->toBeNull(); + }); +}); + +describe('Project task set relationships', function () { + it('can access task sets from project', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techVersion = DocumentVersion::factory()->create(); + + TaskSet::factory()->count(3)->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techVersion->id, + ]); + + expect($project->taskSets)->toHaveCount(3); + }); + + it('can get latest task set from project', function () { + $user = User::factory()->create(); + $project = Project::factory()->for($user)->create(); + + $techVersion = DocumentVersion::factory()->create(); + + $older = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techVersion->id, + 'created_at' => now()->subDay(), + ]); + + $newer = TaskSet::factory()->create([ + 'project_id' => $project->id, + 'source_tech_version_id' => $techVersion->id, + 'created_at' => now(), + ]); + + $latest = $project->latestTaskSet(); + + expect($latest->id)->toBe($newer->id); + }); +});