diff --git a/.gitignore b/.gitignore index 633da0f..b845316 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ coverage/ # SpecFlow workflow session files .specflow/workflows/ +# Dashboard local state +packages/dashboard/.specflow/ diff --git a/.specflow/orchestration-state.json b/.specflow/orchestration-state.json index ce4416e..9689563 100644 --- a/.specflow/orchestration-state.json +++ b/.specflow/orchestration-state.json @@ -5,7 +5,7 @@ "name": "specflow", "path": "/Users/ppatterson/dev/specflow" }, - "last_updated": "2026-01-24T21:57:04.195Z", + "last_updated": "2026-02-02T00:49:27.213Z", "orchestration": { "phase": { "id": null, @@ -21,7 +21,8 @@ "Eliminate race conditions - Atomic writes, spawn intent pattern", "Reduce code - Target simplicity" ], - "hasUserGate": true + "hasUserGate": true, + "userGateStatus": "confirmed" }, "next_phase": { "number": "1060", @@ -34,7 +35,7 @@ }, "analyze": { "iteration": null, - "completedAt": 1769189896 + "completedAt": 1769292224 }, "implement": null, "progress": { @@ -42,6 +43,42 @@ "tasks_total": 0, "percentage": 0 }, + "dashboard": { + "active": { + "id": "1058-verify-session", + "startedAt": "2026-02-01T18:30:00.000Z", + "status": "waiting_merge", + "config": { + "autoMerge": false, + "additionalContext": "", + "skipDesign": false, + "skipAnalyze": false, + "skipImplement": false, + "skipVerify": false, + "autoHealEnabled": true, + "maxHealAttempts": 1, + "batchSizeFallback": 15, + "pauseBetweenBatches": false, + "budget": { + "maxPerBatch": 5, + "maxTotal": 50, + "healingBudget": 2, + "decisionBudget": 0.5 + } + } + }, + "batches": { + "total": 0, + "current": 0, + "items": [] + }, + "cost": { + "total": 0, + "perBatch": [] + }, + "decisionLog": [], + "lastWorkflow": null + }, "steps": {} }, "health": { @@ -68,6 +105,15 @@ "completed_at": "2026-01-24T21:57:04.195Z", "tasks_completed": 0, "tasks_total": 0 + }, + { + "type": "phase_completed", + "phase_number": "1058", + "phase_name": "Single State Consolidation", + "branch": "1058-single-state-consolidation", + "completed_at": "2026-02-02T00:49:27.213Z", + "tasks_completed": 0, + "tasks_total": 0 } ] } diff --git a/.specify/archive/1058-single-state-consolidation/RESUME_PLAN.md b/.specify/archive/1058-single-state-consolidation/RESUME_PLAN.md new file mode 100644 index 0000000..1e3fd0b --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/RESUME_PLAN.md @@ -0,0 +1,65 @@ +# Resume Plan - Single State Consolidation (Phase 1058) + +Last updated: 2026-02-01 (Phase 2 complete) +Branch: 1058-single-state-consolidation +Last commit: 94a256f (phase2: harden orchestration status + cancel flow) +Remote: origin/1058-single-state-consolidation (pushed) +Working tree: clean + +## Why this file exists +Compact, actionable context so work can resume quickly after interruption. + +## Current State (after Phase 6 refactor) +### ✅ Stabilization & runtime aggregation complete +- Phase 0/1 fixes are in place (merge step mapping, running indicator accuracy, index rebuild, session end handling). +- Runtime aggregator uses JSONL/metadata health (no stale `index.json` reliance). + +### ✅ CLI state is the single source of truth +- Orchestration dashboard state lives under `orchestration.dashboard` in CLI state. +- Dashboard service reads/writes only CLI state (no legacy orchestration files). +- Runner + API routes use CLI state-backed updates. + +### ✅ Decision logic simplified +- `orchestration-decisions.ts` rewritten to a small, state-based decision matrix. +- Removed legacy decision adapters, staleness backoff, Claude analyzer fallback. +- Removed workflow lookup fallback and batch completion guards. +- Runner now uses `readOrchestrationStep()` + `readDashboardState()` for inputs. + +### ✅ Auto-heal simplified +- `autoHealAfterWorkflow` now reads CLI step state and only updates when step matches the workflow skill. +- State healing is deterministic (no Claude fallback). + +### ✅ Tests updated +- Decision tests rewritten for the simplified matrix. +- Runner tests updated to mock `readDashboardState` + `readOrchestrationStep`. +- Removed obsolete Claude fallback test block and old OrchestrationDeps fixtures. + +### ✅ UI Step Override complete +- StepOverride UI is wired in context drawer. +- `goBackToStep` uses CLI state set and clears last workflow. +- API now blocks when an external workflow is active and restarts the runner if needed. + +### ✅ Phase 2 state consistency fixes (complete) +- Header/session indicator now reflects orchestration status when no workflow is active. +- Step override resets batches/cost when going back to design/analyze/implement. +- Orchestration start treats `specflow status` failures as needing design/open to avoid tasks.md errors. +- CLI session discovery now marks stale instead of “completed” after inactivity. +- Session cancel by sessionId attempts SIGINT → SIGTERM/SIGKILL and surfaces warning toast when forced. + +## Remaining Work +1) **Deferred cleanup (optional)** + - Remove `OrchestrationExecution` compatibility layer and schema once UI is migrated. + +## Key Files (recently touched) +- `packages/dashboard/src/lib/services/orchestration-decisions.ts` +- `packages/dashboard/src/lib/services/orchestration-runner.ts` +- `packages/dashboard/src/lib/services/orchestration-service.ts` +- `packages/dashboard/tests/orchestration/orchestration-decisions.test.ts` +- `packages/dashboard/tests/orchestration/orchestration-runner.test.ts` +- `packages/dashboard/tests/fixtures/orchestration/helpers.ts` + +## How to Resume +1) Run quick sanity checks (lint/tests) if desired. +2) Implement Phase 7 UI step override. +3) Decide whether to remove `OrchestrationExecution` compatibility layer. +4) Update plan/status docs and commit/push. diff --git a/.specify/archive/1058-single-state-consolidation/SIMPLIFICATION_PLAN.md b/.specify/archive/1058-single-state-consolidation/SIMPLIFICATION_PLAN.md new file mode 100644 index 0000000..b25fbc3 --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/SIMPLIFICATION_PLAN.md @@ -0,0 +1,365 @@ +# Orchestration Simplification Plan + +## Problem Statement + +The dashboard's orchestration system has become a mess of hacks working around edge cases instead of having a clean design. There are multiple sources of truth (CLI state file vs dashboard OrchestrationExecution), reconciliation hacks, guards that block decisions after they're already wrong, and a Claude analyzer as a fallback when nothing makes sense. + +## Goals + +1. **Single source of truth**: `.specflow/orchestration-state.json` is THE state +2. **Dead simple flow**: design → analyze → implement (batches) → verify → merge +3. **Trust sub-commands**: They update step.status; dashboard auto-heals if needed +4. **Clean decision logic**: No hacks, no guards, no reconciliation between parallel states + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Dashboard │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ +│ │ Orchestration │───>│ Claude CLI │───>│ specflow CLI │ │ +│ │ Runner │ │ Session │ │ state set │ │ +│ └────────┬────────┘ └──────────────────┘ └───────┬───────┘ │ +│ │ │ │ +│ │ watches │ writes │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ .specflow/orchestration-state.json │ │ +│ │ (SINGLE SOURCE OF TRUTH) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Flow**: +1. Dashboard reads state file +2. Dashboard decides what to spawn based on state +3. Dashboard spawns Claude CLI session with skill (/flow.design, etc.) +4. Claude CLI runs skill, skill calls `specflow state set` to update state +5. Dashboard watches state file for changes +6. When session ends AND step.status=complete, move to next step +7. If state doesn't match expectations, auto-heal (simple rules, Claude fallback) + +--- + +## Phase 1: Consolidate State (Remove OrchestrationExecution) + +### Current Problem +- `OrchestrationExecution` in dashboard maintains: currentPhase, batches, status, config, executions, decisionLog, totalCostUsd +- CLI state file maintains: step.current, step.status, step.index, phase info +- These drift apart, causing confusion + +### Solution +Extend the CLI state schema to include dashboard-specific fields: + +```typescript +// Add to orchestration section of state file +orchestration: { + // ... existing fields (step, phase, progress, etc.) + + // NEW: Dashboard orchestration tracking + dashboard: { + // Active orchestration (null if none) + active: { + id: string; // UUID for this orchestration run + startedAt: string; // ISO timestamp + config: OrchestrationConfig; // User's config choices + } | null; + + // Batch tracking for implement phase + batches: { + total: number; + current: number; + items: Array<{ + section: string; + taskIds: string[]; + status: 'pending' | 'running' | 'completed' | 'failed' | 'healed'; + workflowId?: string; + healAttempts: number; + }>; + }; + + // Cost tracking + cost: { + total: number; + perBatch: number[]; + }; + + // Decision log (last 20) + decisionLog: Array<{ + timestamp: string; + action: string; + reason: string; + }>; + + // Last workflow tracking + lastWorkflow: { + id: string; + skill: string; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + } | null; + } +} +``` + +### Tasks +1. [ ] Update `OrchestrationStateSchema` in `packages/shared/src/schemas/events.ts` +2. [ ] Add `specflow state set orchestration.dashboard.*` support +3. [ ] Remove `OrchestrationExecution` type and storage +4. [ ] Update `orchestration-service.ts` to read/write via specflow CLI (or direct file with schema validation) +5. [ ] Remove `orchestration-execution.ts` schema + +--- + +## Phase 2: Simplify Decision Logic + +### Current Problem +- `orchestration-decisions.ts` has complex logic +- `orchestration-runner.ts` has legacy `makeDecision()` plus adapter pattern +- Guards that block transitions after wrong decisions +- Claude analyzer fallback when state is unclear + +### Solution +Simple decision matrix based on state file: + +```typescript +function getNextAction(state: OrchestrationState): Decision { + const { step, dashboard } = state.orchestration; + const workflow = dashboard?.lastWorkflow; + + // 1. If no active orchestration, nothing to do + if (!dashboard?.active) { + return { action: 'idle', reason: 'No active orchestration' }; + } + + // 2. If workflow is running, wait + if (workflow?.status === 'running') { + return { action: 'wait', reason: 'Workflow running' }; + } + + // 3. Based on current step and status + switch (step.current) { + case 'design': + if (step.status === 'complete') return transition('analyze'); + if (step.status === 'failed') return heal('design'); + if (!workflow) return spawn('flow.design'); + return { action: 'wait', reason: 'Design in progress' }; + + case 'analyze': + if (step.status === 'complete') return transition('implement'); + if (step.status === 'failed') return heal('analyze'); + if (!workflow) return spawn('flow.analyze'); + return { action: 'wait', reason: 'Analyze in progress' }; + + case 'implement': + return handleImplementBatches(state); + + case 'verify': + if (step.status === 'complete') return mergeOrWait(state); + if (step.status === 'failed') return heal('verify'); + if (!workflow) return spawn('flow.verify'); + return { action: 'wait', reason: 'Verify in progress' }; + + default: + return { action: 'wait', reason: 'Unknown step' }; + } +} + +function handleImplementBatches(state): Decision { + const { batches } = state.orchestration.dashboard; + + // All batches done? + if (allBatchesComplete(batches)) { + return transition('verify'); + } + + const currentBatch = batches.items[batches.current]; + + // Current batch failed? + if (currentBatch.status === 'failed') { + if (canHeal(currentBatch)) return healBatch(batches.current); + return { action: 'needs_attention', reason: 'Batch failed' }; + } + + // Current batch pending? + if (currentBatch.status === 'pending') { + return spawnBatch(currentBatch); + } + + // Current batch complete? Move to next + if (currentBatch.status === 'completed') { + return advanceBatch(); + } + + return { action: 'wait', reason: 'Batch in progress' }; +} +``` + +### Tasks +1. [ ] Rewrite `orchestration-decisions.ts` with simplified logic above +2. [ ] Remove legacy `makeDecision()` from runner +3. [ ] Remove `createDecisionInput()` adapter +4. [ ] Remove guards that block after-the-fact +5. [ ] Remove Claude analyzer fallback (replaced by simple heal logic) + +--- + +## Phase 3: Fix State Transitions + +### Current Problem +- Dashboard tries to reconcile its currentPhase with CLI's step.current +- Hack at line 889-893: "if mismatch, treat as not_started" +- `isPhaseComplete()` checks artifacts instead of trusting state + +### Solution +Trust the state file. Period. + +```typescript +// REMOVE THIS: +const stepStatus = (stateFileStep === orchestration.currentPhase && rawStatus && ...) + ? rawStatus + : 'not_started'; // HACK + +// REPLACE WITH: +const stepStatus = state.orchestration.step.status; +const stepCurrent = state.orchestration.step.current; +// That's it. Trust the state. +``` + +### Auto-Heal Rules (Simple) + +After a workflow ends, check state matches expectations: + +| Skill | Expected State | Auto-Heal If | +|-------|---------------|--------------| +| flow.design | step.current=design, step.status=complete | status != complete → set to complete | +| flow.analyze | step.current=analyze, step.status=complete | status != complete → set to complete | +| flow.implement | (batch-specific) | batch status not updated → mark complete | +| flow.verify | step.current=verify, step.status=complete | status != complete → set to complete | + +If heal rule doesn't apply (ambiguous case), spawn Claude helper to analyze and fix. + +### Tasks +1. [ ] Remove `isPhaseComplete()` function (or make it only check state) +2. [ ] Remove state reconciliation hack (line 889-893) +3. [ ] Add `autoHealAfterWorkflow()` function with simple rules +4. [ ] Add Claude helper fallback for ambiguous cases only + +--- + +## Phase 4: Clean Up Batch Handling + +### Current Problem +- Batch completion uses `every()` on empty array (returns true = bug) +- Guards prevent implement→verify transition when batches incomplete +- Batches initialized late (during implement transition) + +### Solution +Initialize batches when orchestration starts, track in state file: + +```typescript +async function startOrchestration(projectPath: string, config: OrchestrationConfig) { + // 1. Parse batches from tasks.md NOW + const batchPlan = parseBatchesFromProject(projectPath, config.batchSizeFallback); + + // 2. Initialize state with batches + await execAsync(`specflow state set \ + orchestration.dashboard.active.id=${uuid()} \ + orchestration.dashboard.active.startedAt=${new Date().toISOString()} \ + orchestration.dashboard.batches.total=${batchPlan.batches.length} \ + orchestration.dashboard.batches.current=0 \ + orchestration.dashboard.batches.items='${JSON.stringify(batchPlan.batches)}' + `); + + // 3. Start from current step (trust state file) + // Decision logic will spawn appropriate workflow +} +``` + +### Tasks +1. [ ] Move batch initialization to orchestration start +2. [ ] Update batch status via `specflow state set` not direct writes +3. [ ] Remove empty array guards (not needed if initialized properly) +4. [ ] Remove batch-specific guards in executeDecision + +--- + +## Phase 5: Remove Hacks + +List of specific hacks to remove once above is implemented: + +| Location | Hack | Remove When | +|----------|------|-------------| +| runner:889-893 | State reconciliation | Phase 3 complete | +| runner:1134-1142 | Workflow lookup fallback | Phase 1 complete (tracked in state) | +| runner:1450-1454 | Claude analyzer fallback | Phase 2 complete | +| runner:1570-1584 | Batch completion guard | Phase 4 complete | +| runner:1030-1037 | Empty array guard | Phase 4 complete | +| service:291-295 | Circular phase completion | Phase 3 complete | + +--- + +## Phase 6: UI Enhancements + +### Manual Step Override +Add ability for user to manually go back to a previous step: + +```tsx +// In OrchestrationProgress or similar + +``` + +Implementation: +```typescript +async function setStepManually(step: string) { + await execAsync(`specflow state set \ + orchestration.step.current=${step} \ + orchestration.step.status=not_started + `); + // Orchestration runner will detect change and spawn appropriate workflow +} +``` + +### Tasks +1. [ ] Add step override buttons to UI +2. [ ] Show current state clearly (what step we're on, what status) +3. [ ] Add warning when external changes detected + +--- + +## Implementation Order + +1. **Phase 1**: Consolidate state (biggest change, enables everything else) +2. **Phase 4**: Clean up batch handling (depends on Phase 1) +3. **Phase 3**: Fix state transitions (depends on Phase 1) +4. **Phase 2**: Simplify decision logic (depends on Phase 1, 3, 4) +5. **Phase 5**: Remove hacks (depends on all above) +6. **Phase 6**: UI enhancements (can be parallel) + +--- + +## Success Criteria + +- [ ] Single state file (no OrchestrationExecution) +- [ ] Decision logic < 100 lines (currently ~700) +- [ ] No reconciliation hacks +- [ ] No guards that block after wrong decisions +- [ ] No Claude analyzer fallback (simple heal rules only) +- [ ] User can manually override step if needed +- [ ] External runs (manual /flow.implement) don't break orchestration + +--- + +## Scope Clarifications + +**In Scope (if needed for state management)**: +- Updates to /flow.* commands for state-setting logic +- Updates to specflow CLI core commands for state management +- Schema extensions for dashboard tracking + +**Out of Scope**: +- Major UI redesign (just adding step override) +- Changes to /flow.* command core logic (design artifacts, TDD workflow, etc.) diff --git a/.specify/archive/1058-single-state-consolidation/checklists/implementation.md b/.specify/archive/1058-single-state-consolidation/checklists/implementation.md new file mode 100644 index 0000000..03cb388 --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/checklists/implementation.md @@ -0,0 +1,57 @@ +# Implementation Checklist: Phase 1058 + +## Pre-Implementation + +- [ ] I-001 Review existing orchestration-decisions.ts code (~700 lines) +- [ ] I-002 Review existing orchestration-service.ts OrchestrationExecution usage +- [ ] I-003 Identify all files importing OrchestrationExecution type + +## Schema Extension (Phase 1) + +- [ ] I-010 DashboardState schema validates correctly +- [ ] I-011 Nested field access works: `specflow state get orchestration.dashboard.active.id` +- [ ] I-012 Array field access works: `specflow state set orchestration.dashboard.batches.items=[...]` + +## Migration (Phase 2) + +- [ ] I-020 start() creates dashboard state in CLI state file +- [ ] I-021 get() reads from CLI state file +- [ ] I-022 No OrchestrationExecution imports remain +- [ ] I-023 orchestration-execution.ts deleted + +## Decision Logic (Phase 3) + +- [ ] I-030 getNextAction() function exists and is < 100 lines +- [ ] I-031 makeDecision() and makeDecisionWithAdapter() removed +- [ ] I-032 createDecisionInput() adapter removed +- [ ] I-033 Runner uses getNextAction() with CLI state + +## Auto-Heal (Phase 4) + +- [ ] I-040 autoHealAfterWorkflow() function exists +- [ ] I-041 Heal triggers when workflow ends +- [ ] I-042 Logs show healing actions when they occur + +## Hack Removal (Phase 5) + +- [ ] I-050 State reconciliation hack removed (grep: "stateFileStep === orchestration.currentPhase") +- [ ] I-051 Workflow lookup fallback removed (grep: "Workflow.*lookup failed, waiting") +- [ ] I-052 Claude analyzer fallback removed (grep: "analyzeStateWithClaude") +- [ ] I-053 Batch completion guard removed (grep: "BLOCKED: Cannot transition") +- [ ] I-054 Empty array guard removed (grep: "batches.items.length > 0 && completedCount") +- [ ] I-055 isPhaseComplete() simplified (no hasPlan, hasTasks, hasSpec checks) + +## UI Override (Phase 6) + +- [ ] I-060 goBackToStep() function exists +- [ ] I-061 StepOverride component renders buttons +- [ ] I-062 Clicking button updates state and orchestration resumes + +## Final Verification + +- [ ] I-070 `wc -l orchestration-decisions.ts` shows < 100 lines +- [ ] I-071 `grep -r "OrchestrationExecution" packages/` returns no results +- [ ] I-072 `grep -r "analyzeStateWithClaude" packages/` returns no results +- [ ] I-073 Manual test: Run orchestration end-to-end +- [ ] I-074 Manual test: Run /flow.implement externally, resume in dashboard +- [ ] I-075 Manual test: Click "Go back to Analyze" in UI diff --git a/.specify/archive/1058-single-state-consolidation/checklists/verification.md b/.specify/archive/1058-single-state-consolidation/checklists/verification.md new file mode 100644 index 0000000..f64a086 --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/checklists/verification.md @@ -0,0 +1,113 @@ +# Verification Checklist: Phase 1058 + +## USER GATE Verification + +Before completing this phase, verify ALL criteria: + +### V-001: Single State File +- [ ] `OrchestrationExecution` type is removed from codebase +- [ ] All orchestration state lives in `.specflow/orchestration-state.json` +- [ ] Dashboard reads/writes via CLI or direct file access (no separate store) + +**How to verify**: +```bash +grep -r "OrchestrationExecution" packages/ --include="*.ts" | grep -v ".test." | wc -l +# Should return 0 +``` + +### V-002: Decision Logic is Simple +- [ ] `orchestration-decisions.ts` is < 100 lines +- [ ] No adapter functions +- [ ] No legacy makeDecision functions + +**How to verify**: +```bash +wc -l packages/dashboard/src/lib/services/orchestration-decisions.ts +# Should be < 100 +``` + +### V-003: No Hacks Remain +- [ ] State reconciliation hack removed +- [ ] Workflow lookup fallback removed +- [ ] Claude analyzer fallback removed +- [ ] Batch completion guards removed +- [ ] Empty array guards removed +- [ ] isPhaseComplete() doesn't check artifacts + +**How to verify**: +```bash +# These should all return 0 results: +grep -r "stateFileStep === orchestration.currentPhase" packages/ +grep -r "Workflow.*lookup failed, waiting" packages/ +grep -r "analyzeStateWithClaude" packages/ +grep -r "BLOCKED: Cannot transition" packages/ +grep -r "batches.items.length > 0 && completedCount" packages/ +grep -r "hasPlan === true && hasTasks === true" packages/ +``` + +### V-004: Manual Override Works +- [ ] "Go back to Analyze" button visible in orchestration UI +- [ ] Clicking button updates state file +- [ ] Orchestration resumes from that step + +**How to verify**: +1. Start orchestration, let it reach implement phase +2. Click "Go back to Analyze" +3. Check state file shows step.current=analyze, step.status=not_started +4. Orchestration spawns flow.analyze + +### V-005: External Runs Don't Break +- [ ] Run `/flow.implement` manually from terminal +- [ ] Return to dashboard +- [ ] Dashboard picks up from correct state (doesn't jump to analyze) + +**How to verify**: +1. Dashboard running orchestration, at implement phase +2. Open terminal, run `/flow.implement` manually +3. Wait for it to complete tasks +4. Check dashboard - should continue from verify, not analyze + +## Functional Verification + +### V-010: Full Orchestration Flow +- [ ] Start orchestration from dashboard +- [ ] design → analyze → implement → verify flows correctly +- [ ] Each step completion triggers next step + +### V-011: Batch Handling +- [ ] Multiple batches in implement phase work +- [ ] Pause between batches works (if configured) +- [ ] Batch failure triggers heal attempt + +### V-012: Error Recovery +- [ ] Failed workflow triggers auto-heal +- [ ] Auto-heal sets correct status +- [ ] Manual retry via UI works + +### V-013: Cost Tracking +- [ ] Costs recorded in state file +- [ ] Budget limit respected +- [ ] Per-batch costs tracked + +## Performance Verification + +### V-020: State Operations +- [ ] State reads are fast (< 100ms) +- [ ] State writes are atomic +- [ ] No race conditions in concurrent access + +### V-021: Code Simplification +- [ ] Total lines in orchestration-decisions.ts decreased significantly +- [ ] No duplicate getNextPhase functions +- [ ] No duplicate state tracking + +## Sign-off + +| Verifier | Date | Result | +|----------|------|--------| +| User | | [ ] Pass / [ ] Fail | +| Claude | | Verification complete | + +### Notes + +(Add any observations or issues discovered during verification) diff --git a/.specify/archive/1058-single-state-consolidation/plan.md b/.specify/archive/1058-single-state-consolidation/plan.md new file mode 100644 index 0000000..1b02bb9 --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/plan.md @@ -0,0 +1,162 @@ +# Implementation Plan: Phase 1058 - Single State Consolidation + +## Overview + +This plan consolidates orchestration state into a single, debuggable source of truth and removes the cascading hacks that caused state drift. It also includes a stabilization track (Phase 0/1) that has already been implemented to stop the most visible UI inconsistencies. + +## Status (2026-02-01) + +- Phase 0: Immediate stabilization — DONE. +- Phase 1: Canonical runtime aggregator — DONE. +- Phase 2: CLI state schema extension + dashboard migration — DONE. + - Dashboard defaults now seeded in CLI state init. + - Orchestration service reads/writes only CLI dashboard state. + - Runner + API routes updated to await CLI-backed orchestration writes. +- Phase 3: CLI-state runner simplification — DONE (kept OrchestrationExecution for UI compatibility). +- Phase 4: Decision logic simplification — DONE (getNextAction matrix + runner wiring). +- Phase 5: Auto-heal simplification — DONE (CLI step status healing). +- Phase 6: Hack removal — DONE (Claude fallback + workflow lookup fallback + batch guards removed). +- Phase 7: UI step override — DONE. +- Current behavior: merge step shows correctly, Running indicator is accurate, status API is read-only, phantom sessions eliminated, decision flow is deterministic. + +--- + +## Implementation Phases + +### Phase 0: Immediate Stabilization (DONE) + +**Goal**: Stop the most visible state mismatches and polling loops without changing core orchestration flow. + +**Key fixes**: +- S001: Map CLI `step.current=merge|complete` to UI phase (no fallback to `design`). +- S002: Use `specflow state set` for step sync; remove direct state writes from status API. +- S003: Restrict “active session” UI to `running` / `waiting_for_input` only. +- S004: Update workflow index on session end; rebuild index during process reconciliation. +- S005: Guard cancel actions when no workflow id/session id (dismiss should not cancel). +- S006: Fix failed-but-complete display by treating graceful session ends as `completed`. + +**Acceptance criteria**: +- Merge step displays in sidebar/progress when CLI state is merge. +- Running indicator only appears when an actual workflow is active. +- No phantom active sessions after restart. +- Dismissing a failed banner does not throw. + +--- + +### Phase 1: Canonical Runtime Aggregator (DONE) + +**Goal**: Derive workflow state from a single runtime view instead of `.specflow/workflows/index.json`. + +**Tasks**: +- S101: Add `runtime-state.ts` to build workflow data from metadata + JSONL + health. +- S102: Move CLI session discovery to `workflow-discovery.ts`. +- S103: Update watcher to use `buildWorkflowData()` for workflow events. + +**Acceptance criteria**: +- Session list and current execution are consistent across reloads. +- Stale/detached sessions don’t trigger “running” UI. + +--- + +### Phase 2: Extend CLI State Schema (DONE) + +**Goal**: Add `orchestration.dashboard` section to state file. + +**Files to modify**: +- `packages/shared/src/schemas/events.ts` +- `packages/cli/src/lib/state.ts` + +**Tasks**: +- T001: Add DashboardState schema to shared schema. +- T002: Include dashboard in OrchestrationStateSchema. +- T003: Validate `specflow state set` works with nested dashboard fields. + +--- + +### Phase 3: Migrate Dashboard to CLI State (PARTIAL) + +**Goal**: Read/write CLI state directly. + +**Tasks**: +- T004: Add helpers to read/write dashboard state via CLI. +- T005: Update orchestration-service start() to CLI state. +- T006: Update orchestration-service get() to CLI state. +- T007: Update runner to use CLI state for decisions. +- T008: Remove OrchestrationExecution references (deferred; UI compatibility layer kept). +- T009: Remove orchestration-execution schema (deferred). + +--- + +### Phase 4: Simplify Decision Logic (DONE) + +**Goal**: Replace decision logic with < 100 line state-based matrix. + +**Tasks**: +- T010: Replace makeDecision() with getNextAction(). +- T011: Remove createDecisionInput(). +- T012: Remove legacy decision functions. +- T013: Update runner to call getNextAction(). + +--- + +### Phase 5: Auto-Heal Logic (DONE) + +**Goal**: Simple rules to correct step status after workflow completion. + +**Tasks**: +- T014: Add autoHealAfterWorkflow() in orchestration-runner. +- T015: Call auto-heal on session end. +- T016: Log heal actions clearly. + +--- + +### Phase 6: Remove Hacks (DONE) + +**Goal**: Delete all reconciler/guard hacks that mask state drift. + +**Tasks**: +- T017: Remove state reconciliation hack (runner). +- T018: Remove workflow lookup fallback (runner). +- T019: Remove Claude analyzer fallback (runner). +- T020: Remove batch completion guard (runner). +- T021: Remove empty array guard (runner). +- T022: Simplify isPhaseComplete() to state-only check (service). + +--- + +### Phase 7: UI Step Override (DONE) + +**Goal**: Manual override to move orchestration to a prior step. + +**Tasks**: +- T023: Add goBackToStep() using `specflow state set`. +- T024: Add StepOverride UI component. +- T025: Wire into project detail page. +- T026: Add integration check for external `/flow.*` runs. + +--- + +## Task Summary + +| Phase | Tasks | Description | Status | +|-------|-------|-------------|--------| +| 0 | S001-S006 | Immediate stabilization | DONE | +| 1 | S101-S103 | Canonical runtime aggregator | DONE | +| 2 | T001-T003 | Extend CLI state schema | DONE | +| 3 | T004-T009 | Migrate to CLI state | PARTIAL | +| 4 | T010-T013 | Simplify decision logic | DONE | +| 5 | T014-T016 | Auto-heal logic | DONE | +| 6 | T017-T022 | Remove hacks | DONE | +| 7 | T023-T026 | UI step override | DONE | + +## Execution Order + +1. Phase 2 (schema) enables dashboard migration. +2. Phase 3 (migration) unlocks simplified decision logic. +3. Phases 4–6 in order (each builds on prior). +4. Phase 7 last (UX-only change) — complete. + +## Verification + +- Phase 0/1: merge step shows correctly; Running indicator only when active; dismiss doesn’t error; no polling loop. +- Phase 2+: No OrchestrationExecution type; decision logic < 100 lines; all hacks removed. diff --git a/.specify/archive/1058-single-state-consolidation/spec.md b/.specify/archive/1058-single-state-consolidation/spec.md new file mode 100644 index 0000000..b8f590c --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/spec.md @@ -0,0 +1,188 @@ +# Specification: Phase 1058 - Single State Consolidation + +## Problem Statement + +The dashboard's orchestration system has become a mess of hacks working around edge cases instead of having a clean design. There are: + +1. **Multiple sources of truth**: CLI state file (`.specflow/orchestration-state.json`) AND dashboard's `OrchestrationExecution` state +2. **Reconciliation hacks**: Code that tries to merge/reconcile these two states +3. **Guards blocking wrong decisions**: Guards that prevent transitions AFTER the decision logic already decided wrong +4. **Claude analyzer fallback**: When the system doesn't know what to do, it spawns Claude to figure it out + +This complexity leads to bugs like: +- Jump from verify to analyze when state doesn't match +- External runs (manual `/flow.implement`) breaking orchestration +- Race conditions between state updates + +## Goals + +| # | Goal | Success Criteria | +|---|------|------------------| +| G1 | Single source of truth | `OrchestrationExecution` type removed, all state in CLI state file | +| G2 | Trust sub-commands | Sub-commands update `step.status`; dashboard watches and heals | +| G3 | Simple decision logic | `orchestration-decisions.ts` < 100 lines | +| G4 | No hacks | All 6 identified hacks removed | +| G5 | Manual override | UI button to go back to previous step | + +## Functional Requirements + +### FR-001: Extend CLI State Schema + +Add `orchestration.dashboard` section to `.specflow/orchestration-state.json`: + +```typescript +interface DashboardState { + active: { + id: string; // UUID for this orchestration run + startedAt: string; // ISO timestamp + config: OrchestrationConfig; + } | null; + + batches: { + total: number; + current: number; + items: Array<{ + section: string; + taskIds: string[]; + status: 'pending' | 'running' | 'completed' | 'failed' | 'healed'; + workflowId?: string; + healAttempts: number; + }>; + }; + + cost: { + total: number; + perBatch: number[]; + }; + + decisionLog: Array<{ + timestamp: string; + action: string; + reason: string; + }>; + + lastWorkflow: { + id: string; + skill: string; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + } | null; +} +``` + +### FR-002: Simple Decision Logic + +Decision function must be < 100 lines and follow this pattern: + +```typescript +function getNextAction(state: OrchestrationState): Decision { + const { step, dashboard } = state.orchestration; + + // No active orchestration + if (!dashboard?.active) { + return { action: 'idle', reason: 'No active orchestration' }; + } + + // Workflow running + if (dashboard.lastWorkflow?.status === 'running') { + return { action: 'wait', reason: 'Workflow running' }; + } + + // Based on current step + switch (step.current) { + case 'design': + if (step.status === 'complete') return transition('analyze'); + if (step.status === 'failed') return heal('design'); + return spawn('flow.design'); + + case 'analyze': + if (step.status === 'complete') return transition('implement'); + if (step.status === 'failed') return heal('analyze'); + return spawn('flow.analyze'); + + case 'implement': + return handleBatches(state); + + case 'verify': + if (step.status === 'complete') return mergeOrWait(state); + if (step.status === 'failed') return heal('verify'); + return spawn('flow.verify'); + + default: + return { action: 'wait', reason: 'Unknown step' }; + } +} +``` + +### FR-003: Auto-Heal After Workflow + +When a workflow ends, check if state matches expectations: + +| Skill | Expected After Completion | Auto-Heal If | +|-------|---------------------------|--------------| +| flow.design | step.current=design, step.status=complete | status != complete → set complete | +| flow.analyze | step.current=analyze, step.status=complete | status != complete → set complete | +| flow.implement | batch.status=completed | batch not updated → mark complete | +| flow.verify | step.current=verify, step.status=complete | status != complete → set complete | + +Only use Claude helper for these specific ambiguous cases: +1. State file is corrupted/unparseable (cannot read step.current or step.status) +2. Workflow ended but step.current doesn't match the expected skill (e.g., ran flow.design but step.current=verify) +3. Multiple conflicting signals (workflow completed + session failed + state says in_progress) + +For all other cases, use simple rules or set `needs_attention` for user intervention. + +### FR-004: Remove Hacks + +Remove these specific code sections: + +| Location | Lines | Description | +|----------|-------|-------------| +| orchestration-runner.ts | 889-893 | State reconciliation hack | +| orchestration-runner.ts | 1134-1142 | Workflow lookup fallback | +| orchestration-runner.ts | 1450-1454 | Claude analyzer fallback | +| orchestration-runner.ts | 1570-1584 | Batch completion guard | +| orchestration-runner.ts | 1030-1037 | Empty array guard | +| orchestration-service.ts | 291-295 | Circular phase completion | + +### FR-005: Manual Step Override + +Add UI button to go back to a previous step: + +```typescript +async function setStepManually(step: string) { + await execAsync(`specflow state set \ + orchestration.step.current=${step} \ + orchestration.step.status=not_started + `); + // Orchestration runner detects change and spawns appropriate workflow +} +``` + +## Non-Functional Requirements + +### NFR-001: Code Reduction + +- Decision logic: < 100 lines (from ~700) +- Remove `OrchestrationExecution` type entirely +- Remove `isPhaseComplete()` artifact checks + +### NFR-002: State Consistency + +- Dashboard ONLY reads/writes via `specflow state set` or direct file with schema validation +- No parallel state tracking +- Single file watched for changes + +## Implementation Order + +1. **Extend CLI state schema** (FR-001) - Biggest change, enables everything else +2. **Remove OrchestrationExecution** - Update dashboard to use CLI state +3. **Simplify decision logic** (FR-002) - Now possible with single state +4. **Add auto-heal** (FR-003) - Simple rules +5. **Remove hacks** (FR-004) - No longer needed +6. **Add UI override** (FR-005) - User escape hatch + +## Out of Scope + +- Changes to `/flow.*` command core logic (artifact creation, TDD workflow) +- Major UI redesign (just adding step override button) +- Specflow CLI changes beyond schema extension diff --git a/.specify/archive/1058-single-state-consolidation/tasks.md b/.specify/archive/1058-single-state-consolidation/tasks.md new file mode 100644 index 0000000..25776f2 --- /dev/null +++ b/.specify/archive/1058-single-state-consolidation/tasks.md @@ -0,0 +1,134 @@ +# Tasks: Phase 1058 - Single State Consolidation + +## Phase Goals Coverage + +Phase: 1058 - Single State Consolidation +Source: `.specify/phases/1058-single-state-consolidation.md` + +| # | Phase Goal | Spec Requirement(s) | Task(s) | Status | +|---|------------|---------------------|---------|--------| +| 1 | Single source of truth | FR-001 | T001-T009 | COVERED | +| 2 | Trust sub-commands | FR-003 | T014-T016 | COVERED | +| 3 | Simple decision logic | FR-002 | T010-T013 | COVERED | +| 4 | No hacks | FR-004 | T017-T022 | COVERED | +| 5 | Manual override | FR-005 | T023-T025 | COVERED | + +Coverage: 5/5 goals (100%) + +--- + +## Progress Dashboard + +> Last updated: 2026-01-24 | Run `specflow status` to refresh + +| Phase | Status | Progress | +|-------|--------|----------| +| CLI State Schema | PENDING | 0/3 | +| Migrate to CLI State | PENDING | 0/6 | +| Simplify Decision Logic | PENDING | 0/4 | +| Auto-Heal Logic | PENDING | 0/3 | +| Remove Hacks | PENDING | 0/6 | +| UI Step Override | PENDING | 0/4 | + +**Overall**: 0/26 (0%) | **Current**: T001 + +--- + +## Phase 1: CLI State Schema Extension + +**Purpose**: Add `orchestration.dashboard` section to CLI state file schema + +- [x] T001 Add `DashboardStateSchema` to `packages/shared/src/schemas/events.ts` with active, batches, cost, decisionLog, lastWorkflow fields +- [x] T002 Update `OrchestrationStateSchema` to include optional `dashboard` field in orchestration section +- [x] T003 Test `specflow state set/get` works with new nested dashboard fields (e.g., `orchestration.dashboard.active.id`) + +**Checkpoint**: Can read/write dashboard state via CLI + +--- + +## Phase 2: Migrate Dashboard to CLI State + +**Purpose**: Remove OrchestrationExecution, use CLI state as single source + +- [x] T004 Create `readDashboardState(projectPath)` and `writeDashboardState(projectPath, data)` helpers in `packages/dashboard/src/lib/services/orchestration-service.ts` +- [x] T005 Update `orchestration-service.ts` `start()` to write to CLI state via `specflow state set` instead of creating OrchestrationExecution +- [x] T006 Update `orchestration-service.ts` `get()` to read from CLI state file instead of execution store +- [x] T007 Update `orchestration-runner.ts` main loop to read CLI state for decision input +- [x] T008 Remove all references to `OrchestrationExecution` type throughout dashboard codebase +- [x] T009 Delete `packages/shared/src/schemas/orchestration-execution.ts` and remove exports + +**Checkpoint**: No OrchestrationExecution in codebase + +--- + +## Phase 3: Simplify Decision Logic + +**Purpose**: Rewrite decisions to be < 100 lines, trust state file + +- [x] T010 [P] Replace `makeDecision()` with new `getNextAction()` function (< 100 lines) in `packages/dashboard/src/lib/services/orchestration-decisions.ts` +- [x] T011 [P] Remove `createDecisionInput()` adapter function - no longer needed with single state +- [x] T012 [P] Remove legacy `makeDecision()` and `makeDecisionWithAdapter()` functions +- [x] T013 Update `orchestration-runner.ts` to call new `getNextAction()` with CLI state + +**Checkpoint**: Decision logic < 100 lines + +--- + +## Phase 4: Auto-Heal Logic + +**Purpose**: Simple rules to fix state after workflow completes + +- [x] T014 Add `autoHealAfterWorkflow(state, skill, status)` function in `packages/dashboard/src/lib/services/orchestration-runner.ts` +- [x] T015 Call `autoHealAfterWorkflow()` when workflow session ends (detect via file watcher) +- [x] T016 Add debug logging for heal actions (what was wrong, what was fixed) + +**Checkpoint**: State auto-corrects after workflow completes + +--- + +## Phase 5: Remove Hacks + +**Purpose**: Delete all hack code that's no longer needed + +- [x] T017 Remove state reconciliation hack at `orchestration-runner.ts:889-893` (stepStatus = stateFileStep === currentPhase ? rawStatus : 'not_started') +- [x] T018 Remove workflow lookup fallback at `orchestration-runner.ts:1134-1142` (if existingWorkflowId but no workflow, wait) +- [x] T019 Remove Claude analyzer fallback at `orchestration-runner.ts:1450-1454` (analyzeStateWithClaude on unclear state) +- [x] T020 Remove batch completion guard at `orchestration-runner.ts:1570-1584` +- [x] T021 Remove empty array guard at `orchestration-runner.ts:1030-1037` (batches.items.length > 0 && completedCount) +- [x] T022 Remove or simplify `isPhaseComplete()` in `orchestration-service.ts:278-325` to only check `step.status` (no artifact checks) + +**Checkpoint**: Grep confirms all hacks removed + +--- + +## Phase 6: UI Step Override + +**Purpose**: Allow user to manually go back to previous step + +- [x] T023 Add `goBackToStep(step: string)` function to `packages/dashboard/src/lib/services/orchestration-service.ts` that calls `specflow state set orchestration.step.current={step} orchestration.step.status=not_started` +- [x] T024 Create `StepOverride` component in `packages/dashboard/src/components/orchestration/` that shows buttons to go back to previous steps +- [x] T025 Add `StepOverride` component to project detail page orchestration section +- [x] T026 Add integration test: Run `/flow.implement` externally from terminal, verify dashboard picks up from correct state (doesn't jump to analyze) + +**Checkpoint**: Can click "Go back to Analyze" and orchestration resumes from there; external CLI runs don't break orchestration + +--- + +## Dependencies + +``` +T001-T003 (Schema) → T004-T009 (Migration) → T010-T013 (Decisions) + ↓ + T014-T016 (Auto-Heal) → T017-T022 (Remove Hacks) + ↓ + T023-T025 (UI Override) +``` + +--- + +## Notes + +- [P] = Parallelizable within the phase +- All state writes should go through `specflow state set` for consistency +- Test each phase checkpoint before proceeding +- Reference: `specs/1058-single-state-consolidation/plan.md` for implementation details diff --git a/.specify/history/HISTORY.md b/.specify/history/HISTORY.md index 75f6a30..b2390d4 100644 --- a/.specify/history/HISTORY.md +++ b/.specify/history/HISTORY.md @@ -2,6 +2,102 @@ > Archive of completed development phases. Newest first. +--- + +## 1058 - Single State Consolidation + +**Completed**: 2026-02-02 + +# Phase 1058: Single State Consolidation + +## Overview + +**Goal**: Consolidate the orchestration system to use a single state file (`.specflow/orchestration-state.json`) as the source of truth, eliminating the parallel `OrchestrationExecution` state in the dashboard. + +**Why**: Phase 1057 work revealed that the orchestration system has become a mess of hacks working around edge cases. There are multiple sources of truth (CLI state file vs dashboard OrchestrationExecution), reconciliation hacks, guards that block decisions after they're already wrong, and a Claude analyzer as fallback when nothing makes sense. + +## Phase Goals + +1. **Single source of truth** - `.specflow/orchestration-state.json` is THE state (no OrchestrationExecution) +2. **Trust sub-commands** - Sub-commands update step.status; dashboard watches and auto-heals if needed +3. **Simple decision logic** - Decision logic < 100 lines, based only on state file +4. **Remove all hacks** - No reconciliation, no guards, no Claude analyzer fallback +5. **Manual override** - User can manually go back to previous step via UI + +## USER GATE Criteria + +Before completing this phase, verify: + +1. **Single state file**: `OrchestrationExecution` type is removed, all state lives in `.specflow/orchestration-state.json` +2. **Decision logic is simple**: `orchestration-decisions.ts` is < 100 lines (currently ~700) +3. **No hacks**: Search codebase for removed hacks (state reconciliation, batch guards, Claude analyzer) +4. **Manual override works**: Can click "Go back to Analyze" in UI and orchestration resumes from there + +## Key Changes + +### 1. Extend CLI State Schema + +Add `orchestration.dashboard` section: +```json +{ + "orchestration": { + "dashboard": { + "active": { "id": "uuid", "startedAt": "timestamp", "config": {} }, + "batches": { "total": 3, "current": 0, "items": [...] }, + "cost": { "total": 0, "perBatch": [] }, + "decisionLog": [...], + "lastWorkflow": { "id": "...", "skill": "...", "status": "..." } + } + } +} +``` + +### 2. Simplify Decision Logic + +```typescript +function getNextAction(state): Decision { + // Trust the state file. Period. + const { step, dashboard } = state.orchestration; + + if (!dashboard?.active) return { action: 'idle' }; + if (dashboard.lastWorkflow?.status === 'running') return { action: 'wait' }; + + switch (step.current) { + case 'design': return step.status === 'complete' ? transition('analyze') : spawn('flow.design'); + case 'analyze': return step.status === 'complete' ? transition('implement') : spawn('flow.analyze'); + case 'implement': return handleBatches(state); + case 'verify': return step.status === 'complete' ? mergeOrWait(state) : spawn('flow.verify'); + } +} +``` + +### 3. Auto-Heal After Workflow + +Simple rules when workflow ends: +- If ran flow.design and session completed → expect step.status=complete +- If not, fix it +- Only use Claude for truly ambiguous cases + +### 4. Remove Hacks + +| Hack | What to remove | +|------|----------------| +| State reconciliation | Line 889-893 in orchestration-runner.ts | +| Workflow lookup fallback | Line 1134-1142 in orchestration-runner.ts | +| Claude analyzer | Line 1450-1454 in orchestration-runner.ts | +| Batch guards | Line 1570-1584 in orchestration-runner.ts | +| Circular phase completion | Line 291-295 in orchestration-service.ts | + +## Dependencies + +- Phase 1057 complete (provides the foundation work) +- No external dependencies + +## Reference + +See `specs/1057-orchestration-simplification/SIMPLIFICATION_PLAN.md` for detailed implementation plan (moved to archive but still referenced). + + --- ## 1057 - Orchestration Simplification diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index c22d47b..de6f4e4 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -2,7 +2,7 @@ > Core principles and governance for SpecFlow development. All implementation decisions must align with these principles. -**Version**: 1.3.0 +**Version**: 1.4.0 **Created**: 2026-01-10 **Status**: ACTIVE @@ -78,6 +78,32 @@ Project files are separated into repo knowledge (`.specify/`) and operational st - **`.specflow/`**: orchestration-state.json, manifest.json, workflows/ - delete to uninstall - **Rule**: Never store valuable repo knowledge in `.specflow/`; never store transient operational data in `.specify/` +### IX. Single Source of Truth for State +Each piece of state has ONE authoritative location. No parallel state tracking, no reconciliation. +- **Rationale**: Multiple sources of truth lead to sync bugs, reconciliation hacks, and state confusion +- **Implications**: + - **CLI state file** (`.specflow/orchestration-state.json`) is THE orchestration state + - Dashboard reads CLI state, it does NOT maintain separate state + - Sub-commands (flow.design, flow.implement) own their step state - they set `step.status` + - Dashboard watches and reacts to state changes, it doesn't second-guess them +- **Anti-patterns to AVOID**: + - Separate "execution" objects that mirror CLI state + - "Reconciliation" code that syncs parallel state sources + - Guards that fix state after it's already wrong + - Claude/AI fallback for "unclear state" (if state is unclear, fix the state schema) +- **When state seems wrong**: Fix the ROOT CAUSE. Don't add workarounds that mask the problem. + +### X. No Hacks or Workarounds +When encountering edge cases, fix the root cause. Do not add conditional guards or workarounds. +- **Rationale**: Hacks accumulate. Each hack requires another hack to handle its edge cases. Soon you have unmaintainable spaghetti. +- **Implications**: + - If state can get into an invalid configuration, fix the code that allows it + - If decision logic has ambiguous cases, simplify the state model + - If you need a "guard" to prevent bad behavior, the upstream code is wrong +- **Code Comment Rule**: If you write a comment like `// HACK:`, `// WORKAROUND:`, `// GUARD:`, or `// FIXME:` - STOP. This is a signal to find the real fix, not document the problem. +- **Refactoring Threshold**: If decision logic exceeds 100 lines, it's too complex. Simplify the state model. +- **Phase 1058 Learning**: The orchestration system accumulated 6+ hacks in ~2 months. The fix was 1 week of work. Hacks are NOT faster. + --- ## Governance @@ -107,6 +133,7 @@ To amend this constitution: | Version | Date | Changes | |---------|------|---------| +| 1.4.0 | 2026-01-24 | Added Principles IX (Single Source of Truth) and X (No Hacks) from Phase 1058 learnings | | 1.3.0 | 2026-01-19 | Added Principle VIII: Repo Knowledge vs Operational State (.specify/ vs .specflow/) | | 1.2.0 | 2026-01-18 | Added Principle IIa: TypeScript for CLI Packages; clarified II scope | | 1.1.0 | 2026-01-10 | Added Principle VII: Three-Line Output Rule | diff --git a/.specify/memory/pdrs/workflow-dashboard-orchestration.md b/.specify/memory/pdrs/workflow-dashboard-orchestration.md index 865806e..13f504b 100644 --- a/.specify/memory/pdrs/workflow-dashboard-orchestration.md +++ b/.specify/memory/pdrs/workflow-dashboard-orchestration.md @@ -1,14 +1,15 @@ # PDR: Workflow Dashboard Orchestration -> **Product Design Record** for phases 1048-1070 +> **Product Design Record** for phases 1048-1070 + 1057-1058 > > This document provides the holistic architecture and design decisions for the > workflow dashboard integration feature set. Individual phase files contain > implementation details; this document provides the "why" and overall vision. **Created**: 2026-01-18 +**Updated**: 2026-01-24 (Phase 1058 architectural simplification) **Status**: Approved -**Phases**: 1048, 1050, 1051, 1052, 1055, 1060, 1070 +**Phases**: 1048, 1050, 1051, 1052, 1055, 1057, 1058, 1060, 1070 **POC Reference**: `/debug/workflow` (commit 5dc79dd) --- @@ -23,6 +24,136 @@ from the dashboard and have them run to completion with minimal intervention. handles batching, questions (via notifications), failures (via auto-healing), and transitions between phases. The user returns to find their feature implemented. +--- + +## Phase 1058 Architecture Update: Single State Consolidation + +> **CRITICAL**: This section documents architectural decisions from Phase 1058 that +> supersede earlier designs. All future orchestration work MUST follow these patterns. + +### Problem Statement + +After initial implementation (phases 1048-1055), the orchestration system accumulated +technical debt from edge case handling: + +- **Multiple sources of truth**: CLI state file vs dashboard's `OrchestrationExecution` +- **Reconciliation hacks**: Code to sync parallel state sources +- **Guard code**: Checks that fixed state after it was already wrong +- **Claude analyzer fallback**: AI to interpret "unclear" state +- **Complex decision logic**: 700+ lines of conditional handling + +This pattern is toxic. Each hack requires another hack to handle its edge cases. + +### Architectural Principles (Binding) + +#### 1. CLI State File is THE Single Source of Truth + +``` +.specflow/orchestration-state.json +├── orchestration.step.current → Current step (design/analyze/implement/verify) +├── orchestration.step.status → Step status (not_started/in_progress/complete/failed) +├── orchestration.step.index → Step index (0-3) +├── orchestration.phase.* → Phase metadata +└── orchestration.dashboard.* → Dashboard-specific data (batches, cost, etc.) +``` + +**Dashboard reads this file. Dashboard does NOT maintain separate state.** + +If you find yourself creating a parallel state object, STOP. Use CLI state. + +#### 2. Sub-Commands Own Their State + +| Command | State Responsibility | +|---------|---------------------| +| `/flow.design` | Sets `step.status=complete` when design artifacts created | +| `/flow.analyze` | Sets `step.status=complete` when analysis done | +| `/flow.implement` | Sets `step.status=complete` when tasks done | +| `/flow.verify` | Sets `step.status=complete` when verification passes | + +**Dashboard trusts these settings.** It does NOT verify by checking artifacts exist. + +#### 3. Simple Decision Logic (<100 lines) + +```typescript +function getNextAction(state): Decision { + const { step, dashboard } = state.orchestration; + + // Trust the state file. Period. + if (!dashboard?.active) return { action: 'idle' }; + if (dashboard.lastWorkflow?.status === 'running') return { action: 'wait' }; + + switch (step.current) { + case 'design': return step.status === 'complete' ? transition('analyze') : spawn('flow.design'); + case 'analyze': return step.status === 'complete' ? transition('implement') : spawn('flow.analyze'); + case 'implement': return handleBatches(state); + case 'verify': return step.status === 'complete' ? mergeOrWait(state) : spawn('flow.verify'); + } +} +``` + +If decision logic exceeds 100 lines, the STATE MODEL is too complex. Simplify state, not add more conditionals. + +#### 4. Auto-Heal Pattern (Not Reconciliation) + +When a workflow completes, apply simple healing rules: + +```typescript +function autoHealAfterWorkflow(skill: string, status: string): void { + if (status !== 'completed') return; // Only heal on success + + const expectedStep = skillToStep(skill); // flow.design → design + if (state.step.current === expectedStep && state.step.status !== 'complete') { + state.step.status = 'complete'; // Simple, targeted fix + log(`Auto-healed: ${expectedStep} marked complete after ${skill} succeeded`); + } +} +``` + +**This is NOT reconciliation.** Reconciliation syncs parallel sources. Auto-heal fixes +known edge cases in a SINGLE source. + +#### 5. UI Step Override (User Escape Hatch) + +Users can manually go back to a previous step: + +- Click "Go back to Design" → `step.current=design`, `step.status=not_started` +- Orchestration resumes from that step + +This provides escape from any stuck state without code changes. + +### Anti-Patterns (FORBIDDEN) + +| Anti-Pattern | Why It's Bad | What To Do Instead | +|--------------|--------------|-------------------| +| Separate `OrchestrationExecution` type | Parallel state source | Use CLI state's `orchestration.dashboard` | +| State reconciliation code | Masks root cause, adds complexity | Fix why state diverges | +| "Guard" code that checks then fixes | State shouldn't need guarding | Fix upstream code that creates bad state | +| Claude/AI to interpret unclear state | If state is unclear, schema is wrong | Simplify state schema | +| Decision logic > 100 lines | Complexity breeds bugs | Simplify state model | +| Comments like `// HACK:` or `// WORKAROUND:` | Documents but doesn't fix problem | Find and fix root cause | + +### File Locations + +| File | Purpose | Notes | +|------|---------|-------| +| `packages/dashboard/src/lib/services/orchestration-service.ts` | State operations | Uses CLI state, NOT separate execution files | +| `packages/dashboard/src/lib/services/orchestration-runner.ts` | Main loop | Calls `getNextAction()`, trusts state | +| `packages/dashboard/src/lib/services/orchestration-decisions.ts` | Decision logic | <100 lines, pure functions | +| `packages/dashboard/src/lib/services/orchestration-types.ts` | Type definitions | `OrchestrationExecution` is LOCAL, not shared | +| `.specflow/orchestration-state.json` | THE state file | Single source of truth | + +### Migration Notes + +Phase 1058 removed: +- `packages/shared/src/schemas/orchestration-execution.ts` (parallel state type) +- Legacy `makeDecision()` function (700+ lines → replaced by `getNextAction()` ~80 lines) +- All reconciliation/guard code from runner + +If you need `OrchestrationExecution` type, import from `orchestration-types.ts` (dashboard-local), +NOT from `@specflow/shared`. + +--- + ## Key Principles ### 1. Build on POC, Don't Reinvent @@ -681,3 +812,11 @@ Each phase has a dedicated implementation file with specific deliverables: | Auto-healing | Single retry | Prevents loops, usually succeeds | | Follow-up input | Free-form text | Maximum flexibility | | Start workflow | Both card + detail | User preference, quick access | +| **Phase 1058 Updates** | | | +| State source | CLI state file only | Multiple sources led to sync bugs | +| OrchestrationExecution | Dashboard-local type | Removed from shared, now internal | +| Decision logic | <100 lines | Complex logic = wrong state model | +| Claude fallback | REMOVED | If state is unclear, fix state schema | +| Reconciliation code | REMOVED | Fix root cause, don't mask it | +| Step status ownership | Sub-commands | flow.* sets complete, dashboard trusts | +| UI escape hatch | Step override | User can manually go back to any step | diff --git a/ROADMAP.md b/ROADMAP.md index 38a679d..25cea1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,6 +62,7 @@ This allows inserting urgent work without renumbering existing phases. | 1055 | Smart Batching & Orchestration | ✅ Complete | **USER GATE**: Auto-batch tasks, state machine, auto-healing | | 1056 | JSONL Watcher (Push Updates) | ✅ Complete | **USER GATE**: SSE-based instant updates, no polling delay | | 1057 | Orchestration Simplification | ✅ Complete | **USER GATE**: State-driven orchestration, questions work, Claude Helper | +| 1058 | Single State Consolidation | ✅ Complete | **USER GATE**: Single state file, simple decision logic, no hacks | | 1060 | Stats & Operations | ⬜ Not Started | **USER GATE**: Costs on cards, operations page, basic chart | | 1070 | Cost Analytics | ⬜ Not Started | **USER GATE**: Advanced charts, projections, export | @@ -112,6 +113,7 @@ specflow phase list --complete | **Gate 7** | 1055 | Auto-batching works, state machine transitions, auto-healing attempts | | **Gate 7.5** | 1056 | Session updates within 500ms, questions appear instantly, SSE works | | **Gate 7.6** | 1057 | Orchestration trusts step.status, questions display, Claude Helper works | +| **Gate 7.7** | 1058 | Single state file (no OrchestrationExecution), decision logic < 100 lines, manual step override | | **Gate 8** | 1060 | Costs on cards, session history, basic chart, operations page | | **Gate 9** | 1070 | Advanced charts, projections, CSV/JSON export | diff --git a/commands/flow.init.md b/commands/flow.init.md index 4609262..a0389b4 100644 --- a/commands/flow.init.md +++ b/commands/flow.init.md @@ -270,8 +270,8 @@ Proceed to completion. - Size phases for agentic sessions (~200k tokens) - Place USER GATES at key verification points -3. Create `ROADMAP.md` at project root -4. Create phase detail files in `.specify/phases/` +3. Create `ROADMAP.md` at project root using `specflow phase add` for each phase (this auto-creates `.specify/phases/NNNN-name.md` files) +4. Enhance each phase detail file with goals, scope, and deliverables from discovery context --- diff --git a/commands/flow.merge.md b/commands/flow.merge.md index 33d743a..c2c296e 100644 --- a/commands/flow.merge.md +++ b/commands/flow.merge.md @@ -185,7 +185,7 @@ FEATURE_DIR=$(echo "$STATUS" | jq -r '.context.featureDir') Launch 4 parallel Task agents: Agent 1 (Status): Verify orchestration status - - Check step.current == "verified" (from status already obtained) + - Check step.current == "verify" (from status already obtained) - Check step.status == "complete" → Return: verified status confirmation diff --git a/commands/flow.orchestrate.md b/commands/flow.orchestrate.md index f0a8342..13ee37c 100644 --- a/commands/flow.orchestrate.md +++ b/commands/flow.orchestrate.md @@ -181,16 +181,25 @@ fi **If no active phase** (phase.number is null): -```bash -# Start next phase from ROADMAP -specflow phase open +**IMPORTANT: Only the user should start a new phase.** Do NOT auto-start. Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "No active phase. Would you like to start the next phase from ROADMAP?", + "header": "Start Phase", + "options": [ + {"label": "Yes, start next phase", "description": "Open the next pending phase from ROADMAP.md"}, + {"label": "No, stop here", "description": "Exit orchestration - I'll start a phase manually later"} + ], + "multiSelect": false + }] +} ``` -This command: -- Reads ROADMAP.md to find next pending phase -- Creates feature branch -- Initializes state with phase info -- Sets step to design (index 0) +**Handle response:** +- **Yes, start next phase**: Run `specflow phase open` and continue. This command reads ROADMAP.md, creates a feature branch, initializes state, and sets step to design (index 0). +- **No, stop here**: Exit orchestration with message: "Run `specflow phase open` or `/flow.orchestrate` when ready to start the next phase." **If phase exists but step is null:** diff --git a/commands/flow.roadmap.md b/commands/flow.roadmap.md index 817eff3..39f50a4 100644 --- a/commands/flow.roadmap.md +++ b/commands/flow.roadmap.md @@ -200,7 +200,7 @@ created: YYYY-MM-DD ### 8. Insert Phases -Use CLI to add phases to existing ROADMAP: +Use CLI to add phases to existing ROADMAP. Each command creates both the ROADMAP entry and a `.specify/phases/NNNN-name.md` detail file automatically: ```bash specflow phase add 0010 "core-engine" @@ -210,7 +210,7 @@ specflow phase add 0030 "api-poc" --user-gate --gate "API returns valid data" ### 9. Post-Generation -1. **Write files** - ROADMAP.md and phase files +1. **Enhance phase files** - Update each `.specify/phases/NNNN-name.md` with goals, scope, and deliverables (the CLI creates templates; fill in project-specific details) 2. **Report summary** - total phases, USER GATES, starting point 3. **Suggest commit**: `feat(roadmap): add project roadmap with N phases` @@ -236,12 +236,12 @@ Converts PDRs (Product Design Requirements) from `.specify/memory/pdrs/` into RO 4. **Calculate phase number**: Get next available from ROADMAP -5. **Insert phase**: +5. **Insert phase** (also creates `.specify/phases/NNNN-phase-name.md` automatically): ```bash specflow phase add NNNN "phase-name" --gate "verification criteria" ``` -6. **Create phase file**: `.specify/phases/NNNN-phase-name.md` +6. **Enhance phase file**: Update the auto-created `.specify/phases/NNNN-phase-name.md` with PDR-specific goals, scope, and deliverables 7. **Mark PDR as processed**: Rename with `_` prefix ```bash diff --git a/commands/flow.verify.md b/commands/flow.verify.md index 3299394..778961d 100644 --- a/commands/flow.verify.md +++ b/commands/flow.verify.md @@ -70,6 +70,19 @@ Set [VERIFY] CONTEXT to in_progress. ## Step 1: Get Project Context +**Ensure step is initialized (standalone mode):** + +```bash +CURRENT_STEP=$(specflow state get orchestration.step.current 2>/dev/null) + +# Only set step.current if missing or different (standalone mode) +if [[ -z "$CURRENT_STEP" || "$CURRENT_STEP" == "null" || "$CURRENT_STEP" != "verify" ]]; then + specflow state set orchestration.step.current=verify orchestration.step.index=3 +fi + +specflow state set orchestration.step.status=in_progress +``` + ```bash specflow status --json ``` diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index da079dc..b0a03bd 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -10,13 +10,9 @@ import { getProjectContext, resolveFeatureDir, getMissingArtifacts } from '../li import { runHealthCheck, type HealthIssue } from '../lib/health.js'; import { findProjectRoot, pathExists, getStatePath, getMemoryDir, getTemplatesDir, getSystemTemplatesDir, getHistoryDir, getSpecifyDir } from '../lib/paths.js'; import { handleError, NotFoundError } from '../lib/errors.js'; +import { STEP_INDEX_MAP } from '@specflow/shared'; import type { OrchestrationState } from '@specflow/shared'; -/** - * Step index mapping for validation - */ -const STEP_INDEX_MAP: Record = { design: 0, analyze: 1, implement: 2, verify: 3 }; - /** * Gate types */ @@ -415,15 +411,16 @@ async function applyFixes( // Fix step.index if it's a string if (step && typeof step.index === 'string') { const stepCurrent = step.current as string | undefined; - const correctIndex = stepCurrent && STEP_INDEX_MAP[stepCurrent] !== undefined - ? STEP_INDEX_MAP[stepCurrent] + const stepKey = stepCurrent && stepCurrent in STEP_INDEX_MAP + ? (stepCurrent as keyof typeof STEP_INDEX_MAP) : null; + const correctIndex = stepKey ? STEP_INDEX_MAP[stepKey] : null; step.index = correctIndex; fixCount++; } // Fix step.current if invalid - const validSteps = ['design', 'analyze', 'implement', 'verify']; + const validSteps = Object.keys(STEP_INDEX_MAP); if (step && step.current && !validSteps.includes(step.current as string)) { step.current = null; fixCount++; diff --git a/packages/cli/src/commands/phase/add.ts b/packages/cli/src/commands/phase/add.ts index 760aa02..dce235a 100644 --- a/packages/cli/src/commands/phase/add.ts +++ b/packages/cli/src/commands/phase/add.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { output } from '../../lib/output.js'; import { insertPhaseRow, readRoadmap, type PhaseStatus } from '../../lib/roadmap.js'; import { findProjectRoot } from '../../lib/paths.js'; +import { createPhaseDetailFile } from '../../lib/phases.js'; import { handleError, NotFoundError, ValidationError, StateError } from '../../lib/errors.js'; /** @@ -17,15 +18,17 @@ export interface AddOutput { }; filePath: string; line: number; + phaseDetailPath: string | null; + phaseDetailCreated: boolean; } /** - * Add action - insert a new phase into ROADMAP.md + * Add action - insert a new phase into ROADMAP.md and create phase detail file */ export async function addAction( number: string, name: string, - options: { json?: boolean; gate?: string; userGate?: boolean }, + options: { json?: boolean; gate?: string; userGate?: boolean; file?: boolean }, ): Promise { try { const projectRoot = findProjectRoot(); @@ -79,6 +82,19 @@ export async function addAction( ); } + // Create phase detail file (unless --no-file) + let phaseDetailPath: string | null = null; + const shouldCreateFile = options.file !== false; + + if (shouldCreateFile) { + phaseDetailPath = await createPhaseDetailFile({ + phaseNumber: number, + phaseName: name, + projectPath: projectRoot, + verificationGate, + }); + } + const addOutput: AddOutput = { success: true, phase: { @@ -89,12 +105,20 @@ export async function addAction( }, filePath: result.filePath, line: result.line, + phaseDetailPath, + phaseDetailCreated: phaseDetailPath !== null, }; if (options.json) { output(addOutput); } else { - output(addOutput, `Added phase ${number}: ${name}`); + const lines = [`Added phase ${number}: ${name}`]; + if (phaseDetailPath) { + lines.push(` Phase detail file: ${phaseDetailPath}`); + } else if (shouldCreateFile) { + lines.push(' Phase detail file already exists'); + } + output(addOutput, lines.join('\n')); } } catch (err) { handleError(err); @@ -105,10 +129,11 @@ export async function addAction( * Add command definition */ export const addCommand = new Command('add') - .description('Add a new phase to ROADMAP.md') + .description('Add a new phase to ROADMAP.md and create phase detail file') .argument('', 'Phase number (4 digits, e.g., 0010)') .argument('', 'Phase name (kebab-case, e.g., core-engine)') .option('--json', 'Output as JSON') .option('--gate ', 'Verification gate description') .option('--user-gate', 'Mark as USER GATE (requires user verification)') + .option('--no-file', 'Skip creating phase detail file') .action(addAction); diff --git a/packages/cli/src/commands/phase/close.ts b/packages/cli/src/commands/phase/close.ts index 029fbd1..736db78 100644 --- a/packages/cli/src/commands/phase/close.ts +++ b/packages/cli/src/commands/phase/close.ts @@ -156,9 +156,9 @@ async function closePhase(options: CloseOptions = {}): Promise // Read current state const state = await readState(projectRoot); - const { phase } = state.orchestration; + const phase = state.orchestration?.phase; - if (!phase.number || !phase.name) { + if (!phase?.number || !phase?.name) { throw new ValidationError( 'No active phase', 'Use "specflow phase open" to start a phase first', @@ -267,8 +267,8 @@ async function closePhase(options: CloseOptions = {}): Promise phase_name: phase.name, branch: phase.branch, completed_at: new Date().toISOString(), - tasks_completed: state.orchestration.progress?.tasks_completed ?? 0, - tasks_total: state.orchestration.progress?.tasks_total ?? 0, + tasks_completed: state.orchestration?.progress?.tasks_completed ?? 0, + tasks_total: state.orchestration?.progress?.tasks_total ?? 0, }; // Get existing history or create empty array diff --git a/packages/cli/src/commands/phase/defer.ts b/packages/cli/src/commands/phase/defer.ts index eda8553..6593aaf 100644 --- a/packages/cli/src/commands/phase/defer.ts +++ b/packages/cli/src/commands/phase/defer.ts @@ -28,10 +28,10 @@ async function deferItems( ): Promise { // Read current state to get phase context const state = await readState(projectRoot); - const { phase } = state.orchestration; + const phase = state.orchestration?.phase; // Determine source - current phase or "manual" - const source = phase.number ? `Phase ${phase.number}` : 'Manual'; + const source = phase?.number ? `Phase ${phase.number}` : 'Manual'; // Build deferred items const deferredItems: DeferredItem[] = items.map(description => ({ @@ -42,7 +42,7 @@ async function deferItems( })); // Add to backlog - await addToBacklog(deferredItems, phase.number || 'manual', projectRoot); + await addToBacklog(deferredItems, phase?.number || 'manual', projectRoot); return { action: 'deferred', diff --git a/packages/cli/src/commands/phase/open.ts b/packages/cli/src/commands/phase/open.ts index 31abcea..92eecde 100644 --- a/packages/cli/src/commands/phase/open.ts +++ b/packages/cli/src/commands/phase/open.ts @@ -1,5 +1,3 @@ -import { mkdir, writeFile as fsWriteFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { STEP_INDEX_MAP } from '@specflow/shared'; import { output } from '../../lib/output.js'; import { readState, writeState, setStateValue } from '../../lib/state.js'; @@ -10,23 +8,10 @@ import { calculateNextHotfix, insertPhaseRow, } from '../../lib/roadmap.js'; -import { findProjectRoot, getPhasesDir, pathExists } from '../../lib/paths.js'; +import { findProjectRoot } from '../../lib/paths.js'; +import { phaseSlug, createPhaseDetailFile } from '../../lib/phases.js'; import { handleError, NotFoundError, ValidationError } from '../../lib/errors.js'; -/** - * Sanitize a string for use as a git branch name segment. - * Only allows alphanumeric characters and hyphens. - * Collapses multiple hyphens and trims hyphens from ends. - */ -function sanitizeBranchSegment(name: string): string { - return name - .toLowerCase() - .replace(/\s+/g, '-') // Spaces to hyphens - .replace(/[^a-z0-9-]/g, '') // Remove unsafe characters - .replace(/-+/g, '-') // Collapse multiple hyphens - .replace(/^-|-$/g, ''); // Trim leading/trailing hyphens -} - /** * Phase open output */ @@ -41,60 +26,6 @@ export interface PhaseOpenOutput { message: string; } -/** - * Create a phase detail file in .specify/phases/ - */ -async function createPhaseDetailFile( - phaseNumber: string, - phaseName: string, - projectPath: string, -): Promise { - const phasesDir = getPhasesDir(projectPath); - - // Ensure phases directory exists - if (!pathExists(phasesDir)) { - await mkdir(phasesDir, { recursive: true }); - } - - const slug = sanitizeBranchSegment(phaseName); - const fileName = `${phaseNumber}-${slug}.md`; - const filePath = join(phasesDir, fileName); - - const today = new Date().toISOString().split('T')[0]; - - const content = `# Phase ${phaseNumber}: ${phaseName} - -**Created**: ${today} -**Status**: In Progress - -## Goal - -[Describe the goal of this phase] - -## Scope - -- [List scope items] - -## Deliverables - -- [ ] [Deliverable 1] -- [ ] [Deliverable 2] - -## Verification Gate - -[Define success criteria] - ---- - -## Notes - -[Add any notes or context] -`; - - await fsWriteFile(filePath, content); - return filePath; -} - /** * Open an existing phase from ROADMAP.md */ @@ -132,7 +63,7 @@ async function openExistingPhase( } // Create branch name with sanitized slug - const slug = sanitizeBranchSegment(phase.name); + const slug = phaseSlug(phase.name); const branch = `${phase.number}-${slug}`; // Update state @@ -209,10 +140,15 @@ async function createHotfixPhase( } // Create phase detail file - await createPhaseDetailFile(hotfixNumber, phaseName, projectRoot); + await createPhaseDetailFile({ + phaseNumber: hotfixNumber, + phaseName, + projectPath: projectRoot, + status: 'in_progress', + }); // Create branch name with sanitized slug - const slug = sanitizeBranchSegment(phaseName); + const slug = phaseSlug(phaseName); const branch = `${hotfixNumber}-${slug}`; // Update state diff --git a/packages/cli/src/commands/phase/status.ts b/packages/cli/src/commands/phase/status.ts index 91f8255..69dc726 100644 --- a/packages/cli/src/commands/phase/status.ts +++ b/packages/cli/src/commands/phase/status.ts @@ -2,6 +2,7 @@ import { output } from '../../lib/output.js'; import { readState } from '../../lib/state.js'; import { readRoadmap, getPhaseByNumber } from '../../lib/roadmap.js'; import { findProjectRoot, getSpecsDir, pathExists } from '../../lib/paths.js'; +import { phaseSlug, getPhaseDetailPath } from '../../lib/phases.js'; import { handleError, NotFoundError } from '../../lib/errors.js'; import { join } from 'node:path'; @@ -39,7 +40,7 @@ async function getPhaseStatus(): Promise { // Read state const state = await readState(projectRoot); - const { phase } = state.orchestration; + const phase = state.orchestration?.phase; // Read roadmap for next phase const roadmap = await readRoadmap(projectRoot); @@ -51,8 +52,8 @@ async function getPhaseStatus(): Promise { let hasPlan = false; let hasTasks = false; - if (phase.number && phase.name) { - const slug = phase.name.toLowerCase().replace(/\s+/g, '-'); + if (phase?.number && phase?.name) { + const slug = phaseSlug(phase.name); specDir = join(getSpecsDir(projectRoot), `${phase.number}-${slug}`); if (pathExists(specDir)) { @@ -64,9 +65,8 @@ async function getPhaseStatus(): Promise { // Get phase file path let phaseFile: string | null = null; - if (phase.number && phase.name) { - const slug = phase.name.toLowerCase().replace(/\s+/g, '-'); - const phasePath = join(projectRoot, '.specify', 'phases', `${phase.number}-${slug}.md`); + if (phase?.number && phase?.name) { + const phasePath = getPhaseDetailPath(phase.number, phase.name, projectRoot); if (pathExists(phasePath)) { phaseFile = phasePath; } @@ -74,10 +74,10 @@ async function getPhaseStatus(): Promise { return { phase: { - number: phase.number, - name: phase.name, - status: phase.status, - branch: phase.branch, + number: phase?.number ?? null, + name: phase?.name ?? null, + status: phase?.status ?? 'not_started', + branch: phase?.branch ?? null, }, artifacts: { specDir, diff --git a/packages/cli/src/commands/state/set.ts b/packages/cli/src/commands/state/set.ts index bbc961a..5ebf189 100644 --- a/packages/cli/src/commands/state/set.ts +++ b/packages/cli/src/commands/state/set.ts @@ -2,13 +2,16 @@ import { Command } from 'commander'; import { z } from 'zod'; import { readState, + readRawState, writeState, + writeRawState, setStateValue, getStateValue, parseValue, } from '../../lib/state.js'; import { output, success } from '../../lib/output.js'; import { handleError, ValidationError } from '../../lib/errors.js'; +import { randomUUID } from 'node:crypto'; /** * Output structure for a single state set operation @@ -110,17 +113,52 @@ export const set = new Command('set') parsedPairs.push({ key, value }); } - // All pairs validated, now read state once and apply all updates - let state = await readState(); + // All pairs validated, now read state and apply all updates + // Try validated read first, fall back to forgiving read if validation fails + let state: Record; + let useRawWrite = false; + + try { + state = await readState() as Record; + } catch { + // Validation failed - use forgiving read and auto-repair + const rawResult = await readRawState(); + if (!rawResult.data) { + throw new Error('State file not found or unreadable'); + } + state = rawResult.data; + useRawWrite = true; + + // Auto-repair: if dashboard.active exists but is missing required fields, fill them + const dashboard = (state.orchestration as Record)?.dashboard as Record | undefined; + if (dashboard?.active && typeof dashboard.active === 'object') { + const active = dashboard.active as Record; + if (!active.id) { + active.id = randomUUID(); + } + if (!active.startedAt) { + active.startedAt = new Date().toISOString(); + } + if (!active.config) { + active.config = {}; + } + } + } for (const { key, value } of parsedPairs) { - const previousValue = getStateValue(state, key); + const previousValue = getStateValue(state as never, key); result.updates.push({ key, value, previousValue }); - state = setStateValue(state, key, value); + state = setStateValue(state as never, key, value) as Record; } // Write state once with all updates - await writeState(state); + if (useRawWrite) { + // Update timestamp for raw writes too + state.last_updated = new Date().toISOString(); + await writeRawState(state); + } else { + await writeState(state as never); + } // Success result.status = 'success'; diff --git a/packages/cli/src/commands/state/show.ts b/packages/cli/src/commands/state/show.ts index 97cb813..5677a37 100644 --- a/packages/cli/src/commands/state/show.ts +++ b/packages/cli/src/commands/state/show.ts @@ -39,9 +39,7 @@ export const show = new Command('show') ? 'ok' : phaseStatus === 'in_progress' ? 'pending' - : phaseStatus === 'failed' || phaseStatus === 'blocked' - ? 'error' - : 'pending'; + : 'pending'; status('Status', statusType, phaseStatus); } else { console.log(chalk.dim(' No phase active')); @@ -54,7 +52,7 @@ export const show = new Command('show') keyValue('Step', step.current); const stepStatus = step.status ?? 'unknown'; const stepStatusType = - stepStatus === 'completed' + stepStatus === 'complete' ? 'ok' : stepStatus === 'in_progress' ? 'pending' diff --git a/packages/cli/src/commands/workflow/design.ts b/packages/cli/src/commands/workflow/design.ts index d68055f..3c69514 100644 --- a/packages/cli/src/commands/workflow/design.ts +++ b/packages/cli/src/commands/workflow/design.ts @@ -40,7 +40,7 @@ export async function designAction( if (options.phase && !VALID_PHASES.includes(options.phase)) { const error = `Invalid phase: ${options.phase}. Valid phases: ${VALID_PHASES.join(', ')}`; if (json) { - output({ success: false, status: 'error', error } as DesignOutput, true); + console.log(JSON.stringify({ success: false, status: 'error', error } as DesignOutput, null, 2)); } else { console.error(chalk.red(`ERROR: ${error}`)); } @@ -51,7 +51,7 @@ export async function designAction( const validation = validateClaudeCli(); if (!validation.available) { if (json) { - output({ success: false, status: 'error', error: validation.error } as DesignOutput, true); + console.log(JSON.stringify({ success: false, status: 'error', error: validation.error } as DesignOutput, null, 2)); } else { console.error(chalk.red(`ERROR: ${validation.error}`)); } diff --git a/packages/cli/src/commands/workflow/status.ts b/packages/cli/src/commands/workflow/status.ts index 97f30f7..041b2a5 100644 --- a/packages/cli/src/commands/workflow/status.ts +++ b/packages/cli/src/commands/workflow/status.ts @@ -15,9 +15,9 @@ interface StatusOutput { currentPhase: string | null; pendingQuestions: number; step: { - current: string; - index: number; - status: string; + current: string | null; + index: number | null; + status: string | null; } | null; } @@ -40,7 +40,7 @@ export async function workflowStatusAction( let currentPhase: string | null = null; if (state.orchestration?.step?.status === 'in_progress') { - workflowStatus = pending.length > 0 ? 'waiting_for_answer' : 'running'; + workflowStatus = pending.length > 0 ? 'waiting_for_input' : 'running'; currentPhase = state.orchestration.step.current || null; } else if (state.orchestration?.step?.status === 'complete') { workflowStatus = 'completed'; @@ -55,9 +55,9 @@ export async function workflowStatusAction( pendingQuestions: pending.length, step: state.orchestration?.step ? { - current: state.orchestration.step.current, - index: state.orchestration.step.index, - status: state.orchestration.step.status, + current: state.orchestration.step.current ?? null, + index: state.orchestration.step.index ?? null, + status: state.orchestration.step.status ?? null, } : null, }; @@ -71,7 +71,7 @@ export async function workflowStatusAction( ? chalk.green : workflowStatus === 'failed' ? chalk.red - : workflowStatus === 'waiting_for_answer' + : workflowStatus === 'waiting_for_input' ? chalk.yellow : chalk.gray; @@ -80,7 +80,7 @@ export async function workflowStatusAction( `Phase: ${currentPhase || 'none'} | Questions: ${pending.length} pending`, ]; - if (workflowStatus === 'waiting_for_answer') { + if (workflowStatus === 'waiting_for_input') { lines.push(`Next: Run 'specflow workflow answer --list' to see questions`); } else if (workflowStatus === 'idle') { lines.push(`Next: Run 'specflow workflow design' to start`); diff --git a/packages/cli/src/lib/backlog.ts b/packages/cli/src/lib/backlog.ts index 426619d..73c8310 100644 --- a/packages/cli/src/lib/backlog.ts +++ b/packages/cli/src/lib/backlog.ts @@ -1,6 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { pathExists, getSpecsDir, getTemplatesDir } from './paths.js'; +import { phaseSlug } from './phases.js'; /** * Backlog and deferred item handling @@ -31,7 +32,7 @@ export function getDeferredPath( phaseName: string, projectPath: string = process.cwd(), ): string { - const slug = phaseName.toLowerCase().replace(/\s+/g, '-'); + const slug = phaseSlug(phaseName); return join(getSpecsDir(projectPath), `${phaseNumber}-${slug}`, 'checklists', 'deferred.md'); } @@ -139,7 +140,7 @@ export async function scanDeferredItems( phaseName: string, projectPath: string = process.cwd(), ): Promise { - const slug = phaseName.toLowerCase().replace(/\s+/g, '-'); + const slug = phaseSlug(phaseName); const specsDir = getSpecsDir(projectPath); const checklistDir = join(specsDir, `${phaseNumber}-${slug}`, 'checklists'); diff --git a/packages/cli/src/lib/detect.ts b/packages/cli/src/lib/detect.ts index d4010b1..a93bfdd 100644 --- a/packages/cli/src/lib/detect.ts +++ b/packages/cli/src/lib/detect.ts @@ -229,7 +229,7 @@ export async function detectRepoVersion(projectPath: string): Promise= 2 ? 'high' : 'medium', indicators: v3Indicators, - manifest, + manifest: manifest ?? undefined, stateSchemaVersion: state?.schema_version, }; } @@ -240,7 +240,7 @@ export async function detectRepoVersion(projectPath: string): Promise= 2 ? 'high' : 'medium', indicators: v2Indicators, - manifest, + manifest: manifest ?? undefined, stateSchemaVersion: state?.schema_version, }; } @@ -251,7 +251,7 @@ export async function detectRepoVersion(projectPath: string): Promise= 2 ? 'high' : 'medium', indicators: v1Indicators, - manifest, + manifest: manifest ?? undefined, stateSchemaVersion: state?.schema_version, }; } @@ -261,7 +261,7 @@ export async function detectRepoVersion(projectPath: string): Promise; const VALID_STEP_STATUSES = ['not_started', 'pending', 'in_progress', 'complete', 'failed', 'blocked', 'skipped'] as const; const VALID_PHASE_STATUSES = ['not_started', 'in_progress', 'complete'] as const; -const STEP_INDEX_MAP: Record = { design: 0, analyze: 1, implement: 2, verify: 3 }; /** * ABBC naming pattern - 4 digits (e.g., 0010, 0020, 1015) diff --git a/packages/cli/src/lib/history.ts b/packages/cli/src/lib/history.ts index bc4ee16..1e1f216 100644 --- a/packages/cli/src/lib/history.ts +++ b/packages/cli/src/lib/history.ts @@ -1,6 +1,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { getSpecifyDir, pathExists } from './paths.js'; +import { phaseSlug } from './phases.js'; import type { Phase } from './roadmap.js'; /** @@ -23,7 +24,7 @@ export function getPhaseFilePath( phaseName: string, projectPath: string = process.cwd(), ): string { - const slug = phaseName.toLowerCase().replace(/\s+/g, '-'); + const slug = phaseSlug(phaseName); return join(getPhasesDir(projectPath), `${phaseNumber}-${slug}.md`); } diff --git a/packages/cli/src/lib/migrate.ts b/packages/cli/src/lib/migrate.ts index 5c032b1..d9b2975 100644 --- a/packages/cli/src/lib/migrate.ts +++ b/packages/cli/src/lib/migrate.ts @@ -282,13 +282,14 @@ export async function migrateState( // Check new location first, then legacy location if (pathExists(statePath)) { const content = await readFile(statePath, 'utf-8'); - existingState = JSON.parse(content); + const existingStateData = JSON.parse(content) as Record; + existingState = existingStateData; // Check if already v3.0 with existing history - const existingActions = existingState.actions as Record | undefined; + const existingActions = existingStateData.actions as Record | undefined; const existingHistory = (existingActions?.history as PhaseHistoryItem[]) || []; - if (existingState.schema_version === '3.0' && existingHistory.length > 0) { + if (existingStateData.schema_version === '3.0' && existingHistory.length > 0) { return { success: true, action: 'skipped', diff --git a/packages/cli/src/lib/phases.ts b/packages/cli/src/lib/phases.ts new file mode 100644 index 0000000..a36e19e --- /dev/null +++ b/packages/cli/src/lib/phases.ts @@ -0,0 +1,106 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getPhasesDir, pathExists } from './paths.js'; + +/** + * Shared phase utilities — single source of truth for slug generation, + * display name formatting, and phase detail file creation. + */ + +/** + * Convert a phase name to a kebab-case slug safe for filenames and branch names. + * Handles spaces, special characters, and hyphen collapsing. + */ +export function phaseSlug(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Convert a kebab-case slug back to a display name. + * e.g., "core-engine" → "Core Engine" + */ +export function phaseDisplayName(slug: string): string { + return slug + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Get the path for a phase detail file in .specify/phases/ + */ +export function getPhaseDetailPath( + phaseNumber: string, + phaseName: string, + projectPath: string, +): string { + const slug = phaseSlug(phaseName); + return join(getPhasesDir(projectPath), `${phaseNumber}-${slug}.md`); +} + +export interface CreatePhaseDetailOptions { + phaseNumber: string; + phaseName: string; + projectPath: string; + verificationGate?: string; + status?: string; +} + +/** + * Create a phase detail file with YAML frontmatter template. + * Returns the file path if created, or null if the file already exists. + */ +export async function createPhaseDetailFile( + options: CreatePhaseDetailOptions, +): Promise { + const { phaseNumber, phaseName, projectPath, verificationGate, status } = options; + const slug = phaseSlug(phaseName); + const displayName = phaseDisplayName(slug); + const filePath = getPhaseDetailPath(phaseNumber, phaseName, projectPath); + + // Don't overwrite existing files + if (pathExists(filePath)) { + return null; + } + + // Ensure phases directory exists + const phasesDir = getPhasesDir(projectPath); + if (!pathExists(phasesDir)) { + await mkdir(phasesDir, { recursive: true }); + } + + const today = new Date().toISOString().split('T')[0]; + const phaseStatus = status ?? 'not_started'; + const gate = verificationGate ?? '[Define success criteria]'; + + const content = `--- +phase: ${phaseNumber} +name: ${slug} +status: ${phaseStatus} +created: ${today} +updated: ${today} +--- + +# Phase ${phaseNumber}: ${displayName} + +**Goal**: [Describe the goal of this phase] + +**Scope**: +- [Define scope items] + +**Deliverables**: +- [ ] [Deliverable 1] + +**Verification Gate**: ${gate} + +**Estimated Complexity**: [Low/Medium/High] +`; + + await writeFile(filePath, content); + return filePath; +} diff --git a/packages/cli/src/lib/state.ts b/packages/cli/src/lib/state.ts index 8a857d3..db3c463 100644 --- a/packages/cli/src/lib/state.ts +++ b/packages/cli/src/lib/state.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import type { OrchestrationState } from '@specflow/shared'; -import { OrchestrationStateSchema } from '@specflow/shared'; +import { OrchestrationStateSchema, DashboardStateSchema } from '@specflow/shared'; import { getStatePath, pathExists } from './paths.js'; import { NotFoundError, StateError, ValidationError } from './errors.js'; @@ -215,6 +215,7 @@ export function parseValue(valueStr: string): unknown { /** Create a new initial state */ export function createInitialState(projectName: string, projectPath: string): OrchestrationState { const now = new Date().toISOString(); + const dashboardState = DashboardStateSchema.parse({}); return { schema_version: '3.0', @@ -239,6 +240,7 @@ export function createInitialState(projectName: string, projectPath: string): Or status: 'not_started', }, implement: null, + dashboard: dashboardState, }, health: { status: 'initializing', diff --git a/packages/cli/tests/commands/phase/add.test.ts b/packages/cli/tests/commands/phase/add.test.ts new file mode 100644 index 0000000..4549574 --- /dev/null +++ b/packages/cli/tests/commands/phase/add.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../src/lib/paths.js', () => ({ + findProjectRoot: vi.fn(), +})); + +vi.mock('../../../src/lib/roadmap.js', () => ({ + readRoadmap: vi.fn(), + insertPhaseRow: vi.fn(), +})); + +vi.mock('../../../src/lib/phases.js', () => ({ + createPhaseDetailFile: vi.fn(), +})); + +vi.mock('../../../src/lib/output.js', () => ({ + output: vi.fn(), +})); + +vi.mock('../../../src/lib/errors.js', () => ({ + handleError: vi.fn((err) => { throw err; }), + NotFoundError: class extends Error { + constructor(msg: string) { super(msg); } + }, + ValidationError: class extends Error { + constructor(msg: string) { super(msg); } + }, + StateError: class extends Error { + constructor(msg: string) { super(msg); } + }, +})); + +import { findProjectRoot } from '../../../src/lib/paths.js'; +import { readRoadmap, insertPhaseRow } from '../../../src/lib/roadmap.js'; +import { createPhaseDetailFile } from '../../../src/lib/phases.js'; +import { output } from '../../../src/lib/output.js'; +import { addAction } from '../../../src/commands/phase/add.js'; + +describe('phase add command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create both ROADMAP entry and phase detail file', async () => { + vi.mocked(findProjectRoot).mockReturnValue('/project'); + vi.mocked(readRoadmap).mockResolvedValue({ + filePath: '/project/ROADMAP.md', + phases: [], + progress: { total: 0, completed: 0, percentage: 0 }, + }); + vi.mocked(insertPhaseRow).mockResolvedValue({ + inserted: true, + filePath: '/project/ROADMAP.md', + line: 10, + }); + vi.mocked(createPhaseDetailFile).mockResolvedValue( + '/project/.specify/phases/0010-core-engine.md', + ); + + await addAction('0010', 'core-engine', {}); + + expect(insertPhaseRow).toHaveBeenCalledWith( + '0010', 'core-engine', 'not_started', undefined, '/project', + ); + expect(createPhaseDetailFile).toHaveBeenCalledWith({ + phaseNumber: '0010', + phaseName: 'core-engine', + projectPath: '/project', + verificationGate: undefined, + }); + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ + phaseDetailPath: '/project/.specify/phases/0010-core-engine.md', + phaseDetailCreated: true, + }), + expect.stringContaining('Added phase 0010'), + ); + }); + + it('should skip file creation with --no-file', async () => { + vi.mocked(findProjectRoot).mockReturnValue('/project'); + vi.mocked(readRoadmap).mockResolvedValue({ + filePath: '/project/ROADMAP.md', + phases: [], + progress: { total: 0, completed: 0, percentage: 0 }, + }); + vi.mocked(insertPhaseRow).mockResolvedValue({ + inserted: true, + filePath: '/project/ROADMAP.md', + line: 10, + }); + + await addAction('0010', 'core-engine', { file: false }); + + expect(createPhaseDetailFile).not.toHaveBeenCalled(); + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ + phaseDetailPath: null, + phaseDetailCreated: false, + }), + expect.any(String), + ); + }); + + it('should populate gate text in phase detail file', async () => { + vi.mocked(findProjectRoot).mockReturnValue('/project'); + vi.mocked(readRoadmap).mockResolvedValue({ + filePath: '/project/ROADMAP.md', + phases: [], + progress: { total: 0, completed: 0, percentage: 0 }, + }); + vi.mocked(insertPhaseRow).mockResolvedValue({ + inserted: true, + filePath: '/project/ROADMAP.md', + line: 10, + }); + vi.mocked(createPhaseDetailFile).mockResolvedValue( + '/project/.specify/phases/0020-api-poc.md', + ); + + await addAction('0020', 'api-poc', { gate: 'API returns valid data' }); + + expect(createPhaseDetailFile).toHaveBeenCalledWith({ + phaseNumber: '0020', + phaseName: 'api-poc', + projectPath: '/project', + verificationGate: 'API returns valid data', + }); + }); + + it('should report when file already exists', async () => { + vi.mocked(findProjectRoot).mockReturnValue('/project'); + vi.mocked(readRoadmap).mockResolvedValue({ + filePath: '/project/ROADMAP.md', + phases: [], + progress: { total: 0, completed: 0, percentage: 0 }, + }); + vi.mocked(insertPhaseRow).mockResolvedValue({ + inserted: true, + filePath: '/project/ROADMAP.md', + line: 10, + }); + // createPhaseDetailFile returns null when file already exists + vi.mocked(createPhaseDetailFile).mockResolvedValue(null); + + await addAction('0010', 'core-engine', {}); + + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ + phaseDetailPath: null, + phaseDetailCreated: false, + }), + expect.stringContaining('already exists'), + ); + }); + + it('should pass USER GATE text to phase detail file', async () => { + vi.mocked(findProjectRoot).mockReturnValue('/project'); + vi.mocked(readRoadmap).mockResolvedValue({ + filePath: '/project/ROADMAP.md', + phases: [], + progress: { total: 0, completed: 0, percentage: 0 }, + }); + vi.mocked(insertPhaseRow).mockResolvedValue({ + inserted: true, + filePath: '/project/ROADMAP.md', + line: 10, + }); + vi.mocked(createPhaseDetailFile).mockResolvedValue( + '/project/.specify/phases/0030-api-poc.md', + ); + + await addAction('0030', 'api-poc', { + gate: 'API works', + userGate: true, + }); + + expect(createPhaseDetailFile).toHaveBeenCalledWith({ + phaseNumber: '0030', + phaseName: 'api-poc', + projectPath: '/project', + verificationGate: '**USER GATE**: API works', + }); + }); +}); diff --git a/packages/cli/tests/lib/phases.test.ts b/packages/cli/tests/lib/phases.test.ts new file mode 100644 index 0000000..74a735a --- /dev/null +++ b/packages/cli/tests/lib/phases.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/lib/paths.js', () => ({ + getPhasesDir: vi.fn(), + pathExists: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +import { getPhasesDir, pathExists } from '../../src/lib/paths.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { + phaseSlug, + phaseDisplayName, + getPhaseDetailPath, + createPhaseDetailFile, +} from '../../src/lib/phases.js'; + +describe('phases.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('phaseSlug', () => { + it('should convert spaces to hyphens', () => { + expect(phaseSlug('Core Engine')).toBe('core-engine'); + }); + + it('should lowercase the input', () => { + expect(phaseSlug('DATABASE-Schema')).toBe('database-schema'); + }); + + it('should remove special characters', () => { + expect(phaseSlug('API (v2) Design!')).toBe('api-v2-design'); + }); + + it('should collapse multiple hyphens', () => { + expect(phaseSlug('core--engine')).toBe('core-engine'); + }); + + it('should trim leading and trailing hyphens', () => { + expect(phaseSlug('-core-engine-')).toBe('core-engine'); + }); + + it('should pass through already-kebab-case strings', () => { + expect(phaseSlug('core-engine')).toBe('core-engine'); + }); + + it('should handle multiple spaces', () => { + expect(phaseSlug('Phase Command Implementation')).toBe( + 'phase-command-implementation', + ); + }); + + it('should handle empty string', () => { + expect(phaseSlug('')).toBe(''); + }); + }); + + describe('phaseDisplayName', () => { + it('should convert kebab-case to title case', () => { + expect(phaseDisplayName('core-engine')).toBe('Core Engine'); + }); + + it('should handle single word', () => { + expect(phaseDisplayName('migration')).toBe('Migration'); + }); + + it('should handle multi-word slugs', () => { + expect(phaseDisplayName('phase-command-implementation')).toBe( + 'Phase Command Implementation', + ); + }); + }); + + describe('getPhaseDetailPath', () => { + it('should return correct path', () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + + const result = getPhaseDetailPath('0080', 'CLI Migration', '/project'); + + expect(result).toBe('/project/.specify/phases/0080-cli-migration.md'); + }); + + it('should handle kebab-case names', () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + + const result = getPhaseDetailPath('0010', 'core-engine', '/project'); + + expect(result).toBe('/project/.specify/phases/0010-core-engine.md'); + }); + }); + + describe('createPhaseDetailFile', () => { + it('should create file with YAML frontmatter', async () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + vi.mocked(pathExists).mockReturnValue(false); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + const result = await createPhaseDetailFile({ + phaseNumber: '0010', + phaseName: 'core-engine', + projectPath: '/project', + }); + + expect(result).toBe('/project/.specify/phases/0010-core-engine.md'); + expect(writeFile).toHaveBeenCalledOnce(); + + const content = vi.mocked(writeFile).mock.calls[0][1] as string; + expect(content).toContain('phase: 0010'); + expect(content).toContain('name: core-engine'); + expect(content).toContain('status: not_started'); + expect(content).toContain('# Phase 0010: Core Engine'); + expect(content).toContain('[Define success criteria]'); + }); + + it('should populate verification gate from options', async () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + vi.mocked(pathExists).mockReturnValue(false); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await createPhaseDetailFile({ + phaseNumber: '0020', + phaseName: 'api-poc', + projectPath: '/project', + verificationGate: 'API returns valid data', + }); + + const content = vi.mocked(writeFile).mock.calls[0][1] as string; + expect(content).toContain('**Verification Gate**: API returns valid data'); + }); + + it('should not overwrite existing files', async () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + // First call: file exists check (returns true), second would be dir check + vi.mocked(pathExists).mockReturnValue(true); + + const result = await createPhaseDetailFile({ + phaseNumber: '0010', + phaseName: 'core-engine', + projectPath: '/project', + }); + + expect(result).toBeNull(); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it('should create phases directory if missing', async () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + // First call (file exists): false, second call (dir exists): false + vi.mocked(pathExists).mockReturnValue(false); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await createPhaseDetailFile({ + phaseNumber: '0010', + phaseName: 'core-engine', + projectPath: '/project', + }); + + expect(mkdir).toHaveBeenCalledWith('/project/.specify/phases', { recursive: true }); + }); + + it('should use provided status', async () => { + vi.mocked(getPhasesDir).mockReturnValue('/project/.specify/phases'); + vi.mocked(pathExists).mockReturnValue(false); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await createPhaseDetailFile({ + phaseNumber: '0010', + phaseName: 'core-engine', + projectPath: '/project', + status: 'in_progress', + }); + + const content = vi.mocked(writeFile).mock.calls[0][1] as string; + expect(content).toContain('status: in_progress'); + }); + }); +}); diff --git a/packages/dashboard/src/app/api/events/route.ts b/packages/dashboard/src/app/api/events/route.ts index 977cc1c..2b95a76 100644 --- a/packages/dashboard/src/app/api/events/route.ts +++ b/packages/dashboard/src/app/api/events/route.ts @@ -1,4 +1,4 @@ -import { initWatcher, addListener, getCurrentRegistry, getAllStates, getAllTasks, getAllWorkflows, getAllPhases, getAllSessions, startHeartbeat } from '@/lib/watcher'; +import { initWatcher, addListener, getCurrentRegistry, getAllDataParallel, startHeartbeat, scheduleFullWorkflowRefresh } from '@/lib/watcher'; import type { SSEEvent } from '@specflow/shared'; // Initialize watcher on first request @@ -51,8 +51,10 @@ export async function GET(): Promise { }); } + // Load all data in parallel for fast initial load + const { states, tasks, workflows, phases, sessions } = await getAllDataParallel(); + // Send current state data for all projects - const states = await getAllStates(); for (const [projectId, state] of states) { send({ type: 'state', @@ -63,7 +65,6 @@ export async function GET(): Promise { } // Send current tasks data for all projects - const tasks = await getAllTasks(); for (const [projectId, taskData] of tasks) { send({ type: 'tasks', @@ -74,7 +75,6 @@ export async function GET(): Promise { } // Send current workflow data for all projects - const workflows = await getAllWorkflows(); for (const [projectId, workflowData] of workflows) { send({ type: 'workflow', @@ -85,7 +85,6 @@ export async function GET(): Promise { } // Send current phases data for all projects - const phases = await getAllPhases(); for (const [projectId, phasesData] of phases) { send({ type: 'phases', @@ -96,7 +95,6 @@ export async function GET(): Promise { } // Send current session content for active sessions - const sessions = await getAllSessions(); for (const { projectId, sessionId, content } of sessions) { send({ type: 'session:message', @@ -107,6 +105,10 @@ export async function GET(): Promise { }); } + // Schedule a full workflow refresh shortly after initial connection + // to populate CLI sessions that were skipped in fast mode + scheduleFullWorkflowRefresh(); + // Add listener for future events const removeListener = addListener(send); diff --git a/packages/dashboard/src/app/api/workflow/answer/route.ts b/packages/dashboard/src/app/api/workflow/answer/route.ts index 13f1cc2..f402530 100644 --- a/packages/dashboard/src/app/api/workflow/answer/route.ts +++ b/packages/dashboard/src/app/api/workflow/answer/route.ts @@ -10,9 +10,13 @@ import { * Submit answers to a workflow waiting for input and resume execution. * * Request body: - * - id: string (required) - Execution UUID + * - id: string (optional) - Execution UUID (preferred) + * - sessionId: string (optional) - Alternative: lookup by session ID + * - projectId: string (optional) - Required with sessionId * - answers: Record (required) - Key-value answers * + * Must provide either `id` OR both `sessionId` and `projectId`. + * * Response (200): * - Updated WorkflowExecution with status "running" * @@ -36,9 +40,29 @@ export async function POST(request: Request) { ); } - const { id, answers } = parseResult.data; + const { id, sessionId, projectId, answers } = parseResult.data; + + // Resolve execution ID - either directly provided or lookup by session ID + let executionId = id; + if (!executionId && sessionId && projectId) { + const execution = workflowService.getBySession(sessionId, projectId); + if (!execution) { + return NextResponse.json( + { error: `Execution not found for session: ${sessionId}` }, + { status: 404 } + ); + } + executionId = execution.id; + } + + if (!executionId) { + return NextResponse.json( + { error: 'No execution ID could be resolved' }, + { status: 400 } + ); + } - const execution = await workflowService.resume(id, answers); + const execution = await workflowService.resume(executionId, answers); return NextResponse.json(execution); } catch (error) { diff --git a/packages/dashboard/src/app/api/workflow/cancel/route.ts b/packages/dashboard/src/app/api/workflow/cancel/route.ts index cdce3f5..a72f2fc 100644 --- a/packages/dashboard/src/app/api/workflow/cancel/route.ts +++ b/packages/dashboard/src/app/api/workflow/cancel/route.ts @@ -1,5 +1,115 @@ import { NextResponse } from 'next/server'; +import { execFileSync } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; import { workflowService } from '@/lib/services/workflow-service'; +import { getProjectSessionDir } from '@/lib/project-hash'; +import { isPidAlive, killProcess } from '@/lib/services/process-spawner'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function getProjectPath(projectId: string): string | null { + const homeDir = process.env.HOME || ''; + const registryPath = join(homeDir, '.specflow', 'registry.json'); + + if (!existsSync(registryPath)) { + return null; + } + + try { + const content = readFileSync(registryPath, 'utf-8'); + const registry = JSON.parse(content); + const project = registry.projects?.[projectId]; + return project?.path || null; + } catch { + return null; + } +} + +function findSessionPids(sessionFile: string): { pids: number[]; error?: string } { + try { + const output = execFileSync('lsof', ['-t', sessionFile], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const pids = output + .split('\n') + .map((line) => parseInt(line.trim(), 10)) + .filter((pid) => Number.isFinite(pid) && pid > 0); + return { pids }; + } catch { + return { pids: [], error: 'Unable to inspect running processes for this session.' }; + } +} + +async function attemptKillSessionProcess( + projectId: string, + sessionId: string +): Promise<{ killed: number[]; warning?: string }> { + const projectPath = getProjectPath(projectId); + if (!projectPath) { + return { + killed: [], + warning: 'Project not found in registry. Unable to terminate session process.', + }; + } + + const sessionDir = getProjectSessionDir(projectPath); + const sessionFile = join(sessionDir, `${sessionId}.jsonl`); + if (!existsSync(sessionFile)) { + return { killed: [] }; + } + + const { pids, error } = findSessionPids(sessionFile); + if (error) { + return { killed: [], warning: error }; + } + if (pids.length === 0) { + return { killed: [] }; + } + + const uniquePids = Array.from(new Set(pids)); + const killed = new Set(); + let forced = false; + let failed = false; + + for (const pid of uniquePids) { + if (!isPidAlive(pid)) { + continue; + } + try { + process.kill(pid, 'SIGINT'); + } catch { + // Fall through to SIGTERM/SIGKILL + } + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + + for (const pid of uniquePids) { + if (!isPidAlive(pid)) { + killed.add(pid); + continue; + } + const ok = killProcess(pid, false); + forced = forced || ok; + if (ok) { + killed.add(pid); + } else { + failed = true; + } + } + + const warning = failed + ? 'Some session processes could not be terminated. You may need to stop them manually.' + : forced + ? 'Session did not stop after SIGINT; sent SIGTERM/SIGKILL to end it.' + : undefined; + + return { killed: Array.from(killed), warning }; +} /** * POST /api/workflow/cancel?id=&sessionId=&projectId=&status= @@ -41,9 +151,26 @@ export async function POST(request: Request) { // If execution not found but we have session info, try session-based update if (message.includes('not found') && sessionId && projectId) { + // Check if project exists first + const projectPath = getProjectPath(projectId); + if (!projectPath) { + return NextResponse.json( + { error: `Project not found in registry: ${projectId}` }, + { status: 404 } + ); + } + const cancelled = workflowService.cancelBySession(sessionId, projectId, finalStatus); if (cancelled) { - return NextResponse.json({ cancelled: true, sessionId, status: finalStatus }); + const killResult = finalStatus === 'cancelled' + ? await attemptKillSessionProcess(projectId, sessionId) + : { killed: [] }; + return NextResponse.json({ + cancelled: true, + sessionId, + status: finalStatus, + ...killResult, + }); } } @@ -54,12 +181,29 @@ export async function POST(request: Request) { // No execution ID - try session-based update if (sessionId && projectId) { + // Check if project exists first for better error message + const projectPath = getProjectPath(projectId); + if (!projectPath) { + return NextResponse.json( + { error: `Project not found in registry: ${projectId}` }, + { status: 404 } + ); + } + const cancelled = workflowService.cancelBySession(sessionId, projectId, finalStatus); if (cancelled) { - return NextResponse.json({ cancelled: true, sessionId, status: finalStatus }); + const killResult = finalStatus === 'cancelled' + ? await attemptKillSessionProcess(projectId, sessionId) + : { killed: [] }; + return NextResponse.json({ + cancelled: true, + sessionId, + status: finalStatus, + ...killResult, + }); } return NextResponse.json( - { error: `Session not found or not in updatable state: ${sessionId}` }, + { error: `Session not in updatable state: ${sessionId}` }, { status: 404 } ); } diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/cancel/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/cancel/route.ts index 27eb59e..8e6d0ca 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/cancel/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/cancel/route.ts @@ -95,7 +95,7 @@ export async function POST(request: Request) { } // Cancel orchestration - const orchestration = orchestrationService.cancel(projectPath, orchestrationId); + const orchestration = await orchestrationService.cancel(projectPath, orchestrationId); if (!orchestration) { return NextResponse.json( { error: `Orchestration not found: ${orchestrationId}` }, diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/go-back/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/go-back/route.ts new file mode 100644 index 0000000..db809e5 --- /dev/null +++ b/packages/dashboard/src/app/api/workflow/orchestrate/go-back/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { orchestrationService } from '@/lib/services/orchestration-service'; +import { workflowService } from '@/lib/services/workflow-service'; +import { isRunnerActive, runOrchestration } from '@/lib/services/orchestration-runner'; + +// ============================================================================= +// Registry Lookup +// ============================================================================= + +function getProjectPath(projectId: string): string | null { + const homeDir = process.env.HOME || ''; + const registryPath = join(homeDir, '.specflow', 'registry.json'); + + if (!existsSync(registryPath)) { + return null; + } + + try { + const content = readFileSync(registryPath, 'utf-8'); + const registry = JSON.parse(content); + const project = registry.projects?.[projectId]; + return project?.path || null; + } catch { + return null; + } +} + +// ============================================================================= +// POST /api/workflow/orchestrate/go-back (FR-004) +// ============================================================================= + +/** + * POST /api/workflow/orchestrate/go-back + * + * Go back to a previous step in the orchestration (FR-004) + * + * Body: + * - projectId: string - The project ID + * - id: string - The orchestration ID + * - step: string - The step to go back to (design, analyze, implement, verify) + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { projectId, id, step } = body; + + if (!projectId || !id || !step) { + return NextResponse.json( + { error: 'projectId, id, and step are required' }, + { status: 400 } + ); + } + + // Validate step + const validSteps = ['design', 'analyze', 'implement', 'verify']; + if (!validSteps.includes(step)) { + return NextResponse.json( + { error: `Invalid step: ${step}. Must be one of: ${validSteps.join(', ')}` }, + { status: 400 } + ); + } + + // Get project path from registry + const projectPath = getProjectPath(projectId); + if (!projectPath) { + return NextResponse.json( + { error: 'Project not found in registry' }, + { status: 404 } + ); + } + + // Block step override if an external workflow is running + const activeStatuses = new Set(['running', 'waiting_for_input']); + const activeWorkflows = workflowService + .list(projectId) + .filter((workflow) => activeStatuses.has(workflow.status)); + const externalWorkflow = activeWorkflows.find( + (workflow) => workflow.orchestrationId !== id + ); + + if (externalWorkflow) { + return NextResponse.json( + { + error: 'Active workflow detected outside this orchestration. Finish or cancel it before overriding steps.', + }, + { status: 409 } + ); + } + + // Go back to the step + const result = await orchestrationService.goBackToStep(projectPath, id, step); + + if (!result) { + return NextResponse.json( + { error: 'Failed to go back to step' }, + { status: 500 } + ); + } + + if (!isRunnerActive(id)) { + runOrchestration(projectId, id).catch((error) => { + console.error('[API] Failed to restart orchestration runner after go-back:', error); + }); + } + + return NextResponse.json({ + success: true, + orchestration: result, + }); + } catch (error) { + console.error('[API] Failed to go back to step:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/merge/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/merge/route.ts index fe3822b..98b83cc 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/merge/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/merge/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { orchestrationService } from '@/lib/services/orchestration-service'; +import { orchestrationService, writeDashboardState } from '@/lib/services/orchestration-service'; import { workflowService } from '@/lib/services/workflow-service'; import { runOrchestration } from '@/lib/services/orchestration-runner'; @@ -104,7 +104,7 @@ export async function POST(request: Request) { } // Trigger merge in orchestration state - const orchestration = orchestrationService.triggerMerge(projectPath, orchestrationId); + const orchestration = await orchestrationService.triggerMerge(projectPath, orchestrationId); if (!orchestration) { return NextResponse.json( { error: `Orchestration not found or not waiting for merge: ${orchestrationId}` }, @@ -116,7 +116,14 @@ export async function POST(request: Request) { const workflowExecution = await workflowService.start(projectId, '/flow.merge'); // Link the workflow execution to orchestration - orchestrationService.linkWorkflowExecution(projectPath, orchestrationId, workflowExecution.id); + await orchestrationService.linkWorkflowExecution(projectPath, orchestrationId, workflowExecution.id); + await writeDashboardState(projectPath, { + lastWorkflow: { + id: workflowExecution.id, + skill: 'flow.merge', + status: 'running', + }, + }); // Restart the orchestration runner to handle merge completion runOrchestration(projectId, orchestrationId).catch((error) => { diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/pause/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/pause/route.ts index 61ad7c6..77c6e30 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/pause/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/pause/route.ts @@ -101,7 +101,7 @@ export async function POST(request: Request) { } // Pause orchestration (this kills the current workflow process) - const orchestration = orchestrationService.pause(projectPath, orchestrationId); + const orchestration = await orchestrationService.pause(projectPath, orchestrationId); if (!orchestration) { return NextResponse.json( { error: `Orchestration not found or not running: ${orchestrationId}` }, diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/recover/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/recover/route.ts index 5ab060f..9774aab 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/recover/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/recover/route.ts @@ -113,7 +113,7 @@ export async function POST(request: Request) { } // Handle recovery - const orchestration = orchestrationService.handleRecovery(projectPath, orchestrationId, action); + const orchestration = await orchestrationService.handleRecovery(projectPath, orchestrationId, action); if (!orchestration) { return NextResponse.json( { error: 'Failed to handle recovery' }, diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/resume/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/resume/route.ts index 6be8540..6064a0c 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/resume/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/resume/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { orchestrationService } from '@/lib/services/orchestration-service'; -import { runOrchestration } from '@/lib/services/orchestration-runner'; +import { runOrchestration, stopRunner } from '@/lib/services/orchestration-runner'; // ============================================================================= // Request Schema @@ -82,27 +82,50 @@ export async function POST(request: Request) { ); } - // Get orchestration ID + // Get orchestration ID and active orchestration let orchestrationId = id; + const active = orchestrationService.getActive(projectPath); + if (!orchestrationId) { - const active = orchestrationService.getActive(projectPath); if (!active) { return NextResponse.json( - { error: 'No paused orchestration to resume' }, - { status: 400 } - ); - } - if (active.status !== 'paused') { - return NextResponse.json( - { error: `Orchestration is not paused (status: ${active.status})` }, + { error: 'No active orchestration to resume' }, { status: 400 } ); } orchestrationId = active.id; } - // Resume orchestration - const orchestration = orchestrationService.resume(projectPath, orchestrationId); + // Handle "running" orchestration — force-restart the runner. + // The user clicking Resume on a running orchestration means it's stalled. + // Stop any existing runner (which may be stuck) and start a fresh one. + if (active && active.id === orchestrationId && active.status === 'running') { + console.log(`[orchestrate/resume] Force-restarting runner for ${orchestrationId}`); + stopRunner(orchestrationId); + runOrchestration(projectId, orchestrationId).catch((error) => { + console.error('[orchestrate/resume] Runner error:', error); + }); + + return NextResponse.json({ + orchestration: { + id: active.id, + projectId: active.projectId, + status: active.status, + currentPhase: active.currentPhase, + updatedAt: active.updatedAt, + }, + }); + } + + // Standard resume from paused state + if (active && active.id === orchestrationId && active.status !== 'paused') { + return NextResponse.json( + { error: `Orchestration is not paused (status: ${active.status})` }, + { status: 400 } + ); + } + + const orchestration = await orchestrationService.resume(projectPath, orchestrationId); if (!orchestration) { return NextResponse.json( { error: `Orchestration not found or not paused: ${orchestrationId}` }, diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/route.ts index 7f78d16..43bf7a9 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/route.ts @@ -5,6 +5,7 @@ import { OrchestrationConfigSchema, type OrchestrationPhase, type OrchestrationC import { orchestrationService } from '@/lib/services/orchestration-service'; import { parseBatchesFromProject, getBatchPlanSummary } from '@/lib/services/batch-parser'; import { runOrchestration } from '@/lib/services/orchestration-runner'; +import { getSpecflowEnv } from '@/lib/specflow-env'; // ============================================================================= // Skill Mapping @@ -74,6 +75,7 @@ function getSpecflowStatus(projectPath: string): SpecflowStatus | null { cwd: projectPath, encoding: 'utf-8', timeout: 30000, + env: getSpecflowEnv(), }); return JSON.parse(result); } catch { @@ -250,19 +252,28 @@ export async function POST(request: Request) { // Get specflow status for smart decisions const specflowStatus = getSpecflowStatus(projectPath); + const statusUnavailable = !specflowStatus; + + if (statusUnavailable) { + console.warn('[orchestrate] specflow status unavailable, defaulting to design flow'); + } // Check if phase needs to be opened first - const phaseNeedsOpen = needsPhaseOpen(specflowStatus); + const phaseNeedsOpen = statusUnavailable || needsPhaseOpen(specflowStatus); // Check if design needs to run (phase open but no artifacts) - const designNeeded = needsDesign(specflowStatus); + const designNeeded = statusUnavailable || needsDesign(specflowStatus); // Apply smart config based on actual project state // This auto-skips design/analyze if artifacts already exist - const smartConfig = getSmartConfig(specflowStatus, config); + const smartConfig = statusUnavailable + ? { ...config, skipDesign: false } + : getSmartConfig(specflowStatus, config); // Parse batch plan (T025) - only required if design is complete - const batchPlan = parseBatchesFromProject(projectPath, smartConfig.batchSizeFallback); + const batchPlan = (!phaseNeedsOpen && !designNeeded) + ? parseBatchesFromProject(projectPath, smartConfig.batchSizeFallback) + : null; if (!phaseNeedsOpen && !designNeeded && !batchPlan) { // Phase is open, design is done, but no tasks.md found diff --git a/packages/dashboard/src/app/api/workflow/orchestrate/status/route.ts b/packages/dashboard/src/app/api/workflow/orchestrate/status/route.ts index 041b68d..fbe5608 100644 --- a/packages/dashboard/src/app/api/workflow/orchestrate/status/route.ts +++ b/packages/dashboard/src/app/api/workflow/orchestrate/status/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from 'next/server'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { execSync } from 'child_process'; import { orchestrationService } from '@/lib/services/orchestration-service'; import { parseBatchesFromProject } from '@/lib/services/batch-parser'; import { workflowService } from '@/lib/services/workflow-service'; -import type { OrchestrationExecution, OrchestrationPhase } from '@specflow/shared'; +import { isRunnerActive } from '@/lib/services/orchestration-runner'; +import type { OrchestrationExecution } from '@/lib/services/orchestration-types'; +import { getSpecflowEnv } from '@/lib/specflow-env'; // ============================================================================= // Types @@ -50,38 +52,7 @@ interface PreflightStatus { // Registry Lookup // ============================================================================= -/** - * Sync current phase to orchestration-state.json for UI consistency - */ -function syncPhaseToStateFile(projectPath: string, phase: OrchestrationPhase): void { - try { - let statePath = join(projectPath, '.specflow', 'orchestration-state.json'); - if (!existsSync(statePath)) { - statePath = join(projectPath, '.specify', 'orchestration-state.json'); - } - if (!existsSync(statePath)) return; - - const content = readFileSync(statePath, 'utf-8'); - const state = JSON.parse(content); - - // Only update if phase differs (avoid unnecessary writes) - if (state.orchestration?.step?.current !== phase) { - state.orchestration = state.orchestration || {}; - state.orchestration.step = state.orchestration.step || {}; - state.orchestration.step.current = phase; - state.orchestration.step.status = 'in_progress'; - state.last_updated = new Date().toISOString(); - writeFileSync(statePath, JSON.stringify(state, null, 2)); - } - } catch { - // Non-critical - } -} - function getProjectPath(projectId: string): string | null { - const { existsSync, readFileSync } = require('fs'); - const { join } = require('path'); - const homeDir = process.env.HOME || ''; const registryPath = join(homeDir, '.specflow', 'registry.json'); @@ -131,6 +102,7 @@ function getPreflightStatus(projectPath: string): PreflightStatus { cwd: projectPath, encoding: 'utf-8', timeout: 30000, + env: getSpecflowEnv(), }); const status: SpecflowStatus = JSON.parse(result); @@ -253,9 +225,6 @@ export async function GET(request: Request) { return NextResponse.json({ orchestration: null, workflow: null }, { status: 200 }); } - // Sync current phase to state file (ensures UI consistency for project list) - syncPhaseToStateFile(projectPath, orchestration.currentPhase); - // Look up the current workflow to get its sessionId let workflowInfo: { id: string; sessionId?: string; status?: string } | null = null; const currentWorkflowId = getCurrentWorkflowId(orchestration); @@ -288,6 +257,7 @@ export async function GET(request: Request) { recoveryContext: orchestration.recoveryContext, }, workflow: workflowInfo, + runnerActive: isRunnerActive(orchestration.id), }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/packages/dashboard/src/app/projects/[id]/page.tsx b/packages/dashboard/src/app/projects/[id]/page.tsx index 58e6f6e..8d0e06e 100644 --- a/packages/dashboard/src/app/projects/[id]/page.tsx +++ b/packages/dashboard/src/app/projects/[id]/page.tsx @@ -31,7 +31,7 @@ import { toastWorkflowError, } from "@/lib/toast-helpers" import type { ProjectStatus } from "@/lib/action-definitions" -import type { OrchestrationState, Task } from "@specflow/shared" +import type { OrchestrationPhase, OrchestrationState, Task } from "@specflow/shared" import { useWorkflowSkills, type WorkflowSkill } from "@/hooks/use-workflow-skills" import { useOrchestration } from "@/hooks/use-orchestration" @@ -121,9 +121,16 @@ export default function ProjectDetailPage() { await cancelWorkflowAction(workflowExecution?.executionId, workflowExecution?.sessionId) }, [cancelWorkflowAction, workflowExecution]) - const submitAnswers = useCallback(async (answers: Record) => { - if (!workflowExecution?.executionId) throw new Error('No active workflow') - await submitAnswersAction(workflowExecution.executionId, answers) + const submitAnswers = useCallback(async (answers: Record, fallbackSessionId?: string) => { + // Try executionId first, then fall back to sessionId lookup + const executionId = workflowExecution?.executionId + const sessionId = workflowExecution?.sessionId ?? fallbackSessionId + + if (!executionId && !sessionId) { + throw new Error('No active workflow or session') + } + + await submitAnswersAction({ executionId, sessionId }, answers) }, [submitAnswersAction, workflowExecution]) // Workflow skills for autocomplete @@ -135,8 +142,22 @@ export default function ProjectDetailPage() { activeSessionId: orchestrationSessionId, // Session ID from orchestration polling pause: pauseOrchestration, resume: resumeOrchestration, + goBackToStep, // FR-004: Go back to previous step + isGoingBackToStep, // FR-004: Loading state for go-back + isRunnerStalled, } = useOrchestration({ projectId }) + // Derive a single, consistent step for UI (orchestration overrides state) + const effectiveStep = useMemo(() => { + if (orchestration?.status === 'waiting_merge') return 'merge' + return orchestration?.currentPhase ?? state?.orchestration?.step?.current ?? null + }, [orchestration, state]) + + const effectiveStepStatus = useMemo(() => { + if (orchestration?.status === 'waiting_merge') return 'not_started' + return state?.orchestration?.step?.status ?? null + }, [orchestration, state]) + // Check if there's an active orchestration that can be paused const hasActiveOrchestration = !!( orchestration && @@ -149,14 +170,17 @@ export default function ProjectDetailPage() { // Multi-question tracking: stores partial answers until all questions are answered const [partialAnswers, setPartialAnswers] = useState>({}) const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) - - // Reset question tracking when workflow questions change (new question set) - // TODO: T010 - Questions will come via session:question SSE events - const questionsKey = '' - useEffect(() => { - setPartialAnswers({}) - setCurrentQuestionIndex(0) - }, [questionsKey]) + const [dismissedSessionId, setDismissedSessionId] = useState(null) + // Track previous questionsKey to detect actual question changes vs recomputation + const previousQuestionsKeyRef = useRef('') + // Lock in questions when user starts answering to prevent mid-answer recomputation issues + // This ensures consistency even if question sources change during the answer flow + const lockedQuestionsRef = useRef + multiSelect?: boolean + }> | null>(null) // Session viewer drawer state const [isSessionViewerOpen, setIsSessionViewerOpen] = useState(false) @@ -405,37 +429,60 @@ export default function ProjectDetailPage() { projectTasks?.tasks?.find((t) => t.status === 'todo') ?? null , [projectTasks]) + const getQuestionKey = useCallback((question: { question: string; header?: string }) => { + const header = question.header?.trim() + return header && header.length > 0 ? header : question.question + }, []) + // Handle decision toast answer - supports multi-question flows // Defined before handleOmniBoxSubmit since it's called from there // G4.7/G4.8: Questions come via session:question SSE events OR fallback sources const handleDecisionAnswer = useCallback(async (answer: string) => { - // Get questions from SSE map first - const sseQuestions = consoleSessionId ? sessionQuestions.get(consoleSessionId) : undefined - - // Fallback: compute questions from session messages (same logic as decisionQuestions memo) - let fallbackQuestions: Array<{ question: string; options: Array<{ label: string; description?: string }> }> = [] - if (!sseQuestions?.length && sessionMessages.length > 0) { - for (let i = sessionMessages.length - 1; i >= 0; i--) { - const msg = sessionMessages[i] - if (msg.role === 'assistant' && msg.questions && msg.questions.length > 0) { - fallbackQuestions = msg.questions.map((q) => ({ - question: q.question, - options: q.options.map((opt) => ({ label: opt.label, description: opt.description })), - })) - break + // Use locked questions if we're mid-answer flow, otherwise compute fresh + let questions = lockedQuestionsRef.current + + if (!questions) { + // First answer - compute and lock the questions + // Get questions from SSE map first + const sseQuestions = consoleSessionId ? sessionQuestions.get(consoleSessionId) : undefined + + // Fallback: compute questions from session messages (same logic as decisionQuestions memo) + let fallbackQuestions: Array<{ question: string; header?: string; options: Array<{ label: string; description?: string }>; multiSelect?: boolean }> = [] + if (!sseQuestions?.length && sessionMessages.length > 0) { + let seenUserAfter = false + for (let i = sessionMessages.length - 1; i >= 0; i--) { + const msg = sessionMessages[i] + if (msg.role === 'user') { + seenUserAfter = true + } + if (msg.role === 'assistant' && msg.questions && msg.questions.length > 0 && !seenUserAfter) { + fallbackQuestions = msg.questions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((opt) => ({ label: opt.label, description: opt.description })), + multiSelect: q.multiSelect, + })) + break + } } } - } - // Second fallback: StructuredOutput questions - if (!sseQuestions?.length && fallbackQuestions.length === 0 && - sessionWorkflowOutput?.status === 'needs_input' && sessionWorkflowOutput.questions) { - fallbackQuestions = sessionWorkflowOutput.questions.map((q) => ({ - question: q.question, - options: (q.options || []).map((opt) => ({ label: opt.label, description: opt.description })), - })) - } + // Second fallback: StructuredOutput questions + if (!sseQuestions?.length && fallbackQuestions.length === 0 && + sessionWorkflowOutput?.status === 'needs_input' && sessionWorkflowOutput.questions) { + fallbackQuestions = sessionWorkflowOutput.questions.map((q) => ({ + question: q.question, + header: q.header, + options: (q.options || []).map((opt) => ({ label: opt.label, description: opt.description })), + multiSelect: q.multiSelect, + })) + } - const questions = sseQuestions?.length ? sseQuestions : fallbackQuestions + questions = sseQuestions?.length ? sseQuestions : fallbackQuestions + // Lock in questions for the duration of this answer flow + if (questions.length > 0) { + lockedQuestionsRef.current = questions + } + } if (!questions?.length) { console.warn('[handleDecisionAnswer] No questions available to answer') @@ -445,7 +492,9 @@ export default function ProjectDetailPage() { const totalQuestions = questions.length // Store the answer for the current question - const newAnswers = { ...partialAnswers, [String(currentQuestionIndex)]: answer } + const currentQuestion = questions[currentQuestionIndex] + const questionKey = currentQuestion ? getQuestionKey(currentQuestion) : String(currentQuestionIndex) + const newAnswers = { ...partialAnswers, [questionKey]: answer } setPartialAnswers(newAnswers) // Check if we've answered all questions @@ -453,48 +502,33 @@ export default function ProjectDetailPage() { if (answeredCount >= totalQuestions) { // All questions answered - submit all answers together - // For fallback questions (no active execution), resume the session with the answer - const sessionId = selectedConsoleSession?.sessionId ?? workflowExecution?.sessionId ?? consoleSessionId + // Use consoleSessionId as fallback for session lookup (covers historical sessions) + const fallbackSessionId = consoleSessionId ?? undefined try { // Try submitAnswers first (works for active workflow executions) - await submitAnswers(newAnswers) + // Pass fallback session ID for cases where execution tracking was lost + await submitAnswers(newAnswers, fallbackSessionId) // G4.8: Clear questions from map after user answers if (consoleSessionId) { clearSessionQuestions(consoleSessionId) } // Reset state after successful submission + lockedQuestionsRef.current = null setPartialAnswers({}) setCurrentQuestionIndex(0) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - // If execution tracking was lost OR this is a historical session, resume with the answer - const shouldFallbackToResume = errorMessage.includes('expired') || - errorMessage.includes('not found') || - errorMessage.includes('No active workflow') - - if (sessionId && shouldFallbackToResume) { - console.log('[handleDecisionAnswer] Falling back to session resume with answer:', sessionId) - try { - // Format answers for resumption prompt - const answerSummary = Object.entries(newAnswers) - .map(([idx, ans]) => `${idx}: ${ans}`) - .join(', ') - await startWorkflow(`My answers: ${answerSummary}`, { resumeSessionId: sessionId }) - } catch (resumeError) { - const resumeErrorMessage = resumeError instanceof Error ? resumeError.message : 'Unknown error' - toastWorkflowError(`Failed to resume session: ${resumeErrorMessage}`) - } - } else if (!sessionId && shouldFallbackToResume) { - toastWorkflowError('Unable to resume session - session ID not found') - } else { - toastWorkflowError(errorMessage) - } + // The API now supports session ID lookup, so most "not found" errors should be resolved + // If it still fails, show the error to the user + toastWorkflowError(errorMessage) + // G4.8: Clear questions on error too if (consoleSessionId) { clearSessionQuestions(consoleSessionId) } // Reset state on error too + lockedQuestionsRef.current = null setPartialAnswers({}) setCurrentQuestionIndex(0) } @@ -502,7 +536,116 @@ export default function ProjectDetailPage() { // More questions to answer - advance to next question setCurrentQuestionIndex(currentQuestionIndex + 1) } - }, [consoleSessionId, sessionQuestions, clearSessionQuestions, workflowExecution, submitAnswers, startWorkflow, partialAnswers, currentQuestionIndex, sessionMessages, sessionWorkflowOutput, selectedConsoleSession]) + }, [consoleSessionId, sessionQuestions, clearSessionQuestions, submitAnswers, partialAnswers, currentQuestionIndex, sessionMessages, sessionWorkflowOutput, selectedConsoleSession, getQuestionKey]) + + // G4.6/G4.7: Build questions for decision toast from SSE sessionQuestions + // Fall back to extracting questions from session messages if SSE questions not available + const decisionQuestions = useMemo(() => { + if (!consoleSessionId) return [] + if (dismissedSessionId && dismissedSessionId === consoleSessionId) return [] + // Don't show questions for ended sessions - they're stale + if (sessionHasEnded) return [] + + // First, try SSE questions (real-time) + const sseQuestions = sessionQuestions.get(consoleSessionId) + if (sseQuestions && sseQuestions.length > 0) { + return sseQuestions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: q.multiSelect, + })) + } + + // Fallback: Extract questions from session messages + // This handles the case where user navigates to a waiting session + // after the SSE event was already processed + if (sessionMessages.length > 0) { + // Find the last assistant message with questions + let seenUserAfter = false + for (let i = sessionMessages.length - 1; i >= 0; i--) { + const msg = sessionMessages[i] + if (msg.role === 'user') { + seenUserAfter = true + } + // Check for AskUserQuestion tool call questions + if (msg.role === 'assistant' && msg.questions && msg.questions.length > 0 && !seenUserAfter) { + return msg.questions.map((q) => ({ + question: q.question, + header: q.header, + options: q.options.map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: q.multiSelect, + })) + } + } + } + + // Second fallback: Check sessionWorkflowOutput for StructuredOutput questions + // In CLI mode, Claude uses StructuredOutput with status: 'needs_input' + if (sessionWorkflowOutput?.status === 'needs_input' && sessionWorkflowOutput.questions) { + return sessionWorkflowOutput.questions.map((q) => ({ + question: q.question, + header: q.header, + options: (q.options || []).map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: q.multiSelect, + })) + } + + return [] + }, [consoleSessionId, dismissedSessionId, sessionQuestions, sessionMessages, sessionWorkflowOutput, sessionHasEnded]) + + const questionsKey = useMemo(() => { + if (decisionQuestions.length === 0) return '' + return decisionQuestions.map((q) => q.header?.trim() || q.question).join('|') + }, [decisionQuestions]) + + // Reset question tracking when workflow questions change (new question set) + // CRITICAL: Only reset when questions actually change to a NEW set, not on recomputation + // This prevents the race condition where session file updates cause questionsKey to + // recompute mid-answer, wiping out partial answers and causing premature submission. + useEffect(() => { + const previousKey = previousQuestionsKeyRef.current + const isAnswering = Object.keys(partialAnswers).length > 0 + const isNewQuestionSet = questionsKey !== previousKey + const questionsCleared = questionsKey === '' && previousKey !== '' + const questionsArrived = questionsKey !== '' && previousKey === '' + + // Always update the ref to track current questions + previousQuestionsKeyRef.current = questionsKey + + // Reset state when: + // 1. New questions arrived (from empty) - fresh start + // 2. Questions cleared (to empty) - clean up + // 3. Questions actually changed AND we're not mid-answer + if (questionsArrived || questionsCleared || (isNewQuestionSet && !isAnswering)) { + lockedQuestionsRef.current = null + setPartialAnswers({}) + setCurrentQuestionIndex(0) + } + // If we're mid-answer and questions "changed" (likely just recomputed), keep state intact + }, [questionsKey, partialAnswers]) + + // Session status only - NOT orchestration/phase status + // "running" (Live) = session in progress + // "idle" (Ready) = no session in progress + // "waiting" (Needs Input) = waiting for user input via AskUserQuestion + const layoutStatus: WorkflowStatus = useMemo(() => { + // Has pending questions from AskUserQuestion tool + if (decisionQuestions.length > 0) return 'waiting' + + // Use workflow/session status directly + // workflowStatus comes from the active session, not orchestration + return workflowStatus + }, [workflowStatus, decisionQuestions.length]) // Handle OmniBox submit const handleOmniBoxSubmit = useCallback(async (message: string) => { @@ -517,8 +660,8 @@ export default function ProjectDetailPage() { } // G4.7: If waiting for input and we have questions, use the decision handler - const hasQuestions = consoleSessionId && (sessionQuestions.get(consoleSessionId)?.length ?? 0) > 0 - if (workflowStatus === 'waiting' && hasQuestions) { + const hasQuestions = decisionQuestions.length > 0 + if (hasQuestions) { await handleDecisionAnswer(message) return } @@ -590,7 +733,7 @@ export default function ProjectDetailPage() { // Start a new workflow (slash command) handleWorkflowStart(message) - }, [workflowStatus, workflowExecution, handleDecisionAnswer, startWorkflow, handleWorkflowStart, hasSessionEnded, cancelWorkflow, consoleSessionId, selectedConsoleSession, setActiveView, orchestration, resumeOrchestration]) + }, [workflowStatus, workflowExecution, handleDecisionAnswer, startWorkflow, handleWorkflowStart, hasSessionEnded, cancelWorkflow, consoleSessionId, selectedConsoleSession, setActiveView, orchestration, resumeOrchestration, decisionQuestions.length]) // Handle failed toast retry const handleRetry = useCallback(() => { @@ -602,9 +745,20 @@ export default function ProjectDetailPage() { // Handle failed toast dismiss const handleDismiss = useCallback(() => { - // Cancel the failed workflow to clear state - cancelWorkflow() - }, [cancelWorkflow]) + // Cancel the failed workflow to clear state (if active), otherwise clear selection + if (workflowExecution?.executionId || workflowExecution?.sessionId) { + cancelWorkflow() + return + } + setSelectedConsoleSession(null) + setSelectedHistoricalSession(null) + }, [ + cancelWorkflow, + workflowExecution?.executionId, + workflowExecution?.sessionId, + setSelectedConsoleSession, + setSelectedHistoricalSession, + ]) // Handle ending a session by ID (from session console Cancel button) const handleEndSession = useCallback(async (sessionId: string) => { @@ -625,72 +779,50 @@ export default function ProjectDetailPage() { } }, [projectId, selectedConsoleSession, refreshSessionHistory]) - // Handle pausing a session (pauses orchestration if active) - const handlePauseSession = useCallback(async (_sessionId: string) => { - if (hasActiveOrchestration) { - await pauseOrchestration() - refreshSessionHistory() + // Handle dismissing a decision prompt (decline to answer) + const handleQuestionDismiss = useCallback(async () => { + if (!consoleSessionId) { + return } - }, [hasActiveOrchestration, pauseOrchestration, refreshSessionHistory]) - - // G4.6/G4.7: Build questions for decision toast from SSE sessionQuestions - // Fall back to extracting questions from session messages if SSE questions not available - const decisionQuestions = useMemo(() => { - if (!consoleSessionId) return [] - // First, try SSE questions (real-time) - const sseQuestions = sessionQuestions.get(consoleSessionId) - if (sseQuestions && sseQuestions.length > 0) { - return sseQuestions.map((q) => ({ - question: q.question, - options: q.options.map((opt) => ({ - label: opt.label, - description: opt.description, - })), - })) - } + // Hide the toast immediately and clear local question state + setDismissedSessionId(consoleSessionId) + setPartialAnswers({}) + setCurrentQuestionIndex(0) + clearSessionQuestions(consoleSessionId) - // Fallback: Extract questions from session messages - // This handles the case where user navigates to a waiting session - // after the SSE event was already processed - if (sessionMessages.length > 0) { - // Find the last assistant message with questions - for (let i = sessionMessages.length - 1; i >= 0; i--) { - const msg = sessionMessages[i] - // Check for AskUserQuestion tool call questions - if (msg.role === 'assistant' && msg.questions && msg.questions.length > 0) { - return msg.questions.map((q) => ({ - question: q.question, - options: q.options.map((opt) => ({ - label: opt.label, - description: opt.description, - })), - })) - } + try { + if (workflowExecution?.sessionId === consoleSessionId) { + await cancelWorkflow() + } else { + await handleEndSession(consoleSessionId) } + } catch (error) { + console.error('Failed to cancel session after dismissing question:', error) } + }, [consoleSessionId, clearSessionQuestions, cancelWorkflow, handleEndSession, workflowExecution?.sessionId]) - // Second fallback: Check sessionWorkflowOutput for StructuredOutput questions - // In CLI mode, Claude uses StructuredOutput with status: 'needs_input' - if (sessionWorkflowOutput?.status === 'needs_input' && sessionWorkflowOutput.questions) { - return sessionWorkflowOutput.questions.map((q) => ({ - question: q.question, - options: (q.options || []).map((opt) => ({ - label: opt.label, - description: opt.description, - })), - })) + // Handle pausing a session (pauses orchestration if active) + const handlePauseSession = useCallback(async (_sessionId: string) => { + if (hasActiveOrchestration) { + await pauseOrchestration() + refreshSessionHistory() } - - return [] - }, [consoleSessionId, sessionQuestions, sessionMessages, sessionWorkflowOutput]) + }, [hasActiveOrchestration, pauseOrchestration, refreshSessionHistory]) // Show question loading state when status is waiting but questions haven't loaded yet - const isQuestionsLoading = workflowStatus === 'waiting' && decisionQuestions.length === 0 && sessionMessagesLoading + const isQuestionsLoading = layoutStatus === 'waiting' && + decisionQuestions.length === 0 && + sessionMessagesLoading && + !!consoleSessionId && + dismissedSessionId !== consoleSessionId + + const shouldShowDecisionToast = (decisionQuestions.length > 0 || isQuestionsLoading) && + dismissedSessionId !== consoleSessionId // Handle clicking the "Waiting" badge - navigate to session view to show questions const handleStatusClick = useCallback(() => { - if (workflowStatus === 'waiting') { + if (layoutStatus === 'waiting') { // Ensure we're on the session view so the toast is visible if (activeView !== 'session') { setActiveView('session') @@ -698,7 +830,7 @@ export default function ProjectDetailPage() { // Focus the OmniBox for easy response input omniBoxRef.current?.focus() } - }, [workflowStatus, activeView, setActiveView]) + }, [layoutStatus, activeView, setActiveView]) // Loading state if (projectsLoading) { @@ -843,8 +975,13 @@ export default function ProjectDetailPage() { touchedFiles={touchedFiles} totalAdditions={totalAdditions} totalDeletions={totalDeletions} + currentStepOverride={effectiveStep} + stepStatusOverride={effectiveStepStatus} projectId={projectId} projectPath={project.path} + onGoBackToStep={goBackToStep} + isGoingBackToStep={isGoingBackToStep} + isWorkflowRunning={workflowStatus === 'running' || workflowStatus === 'waiting'} /> ) @@ -853,6 +990,7 @@ export default function ProjectDetailPage() { projectPath={project.path} branchName={branchName} workflowStatus={workflowStatus} + layoutStatus={layoutStatus} workflowStartTime={workflowExecution?.startedAt ? new Date(workflowExecution.startedAt) : null} activeView={activeView} onViewChange={setActiveView} @@ -868,7 +1006,7 @@ export default function ProjectDetailPage() { {/* OmniBox at bottom */} {/* Decision Toast - shown when waiting for input (or loading questions) */} - {workflowStatus === 'waiting' && (decisionQuestions.length > 0 || isQuestionsLoading) && ( + {shouldShowDecisionToast && ( )} diff --git a/packages/dashboard/src/components/input/decision-toast.tsx b/packages/dashboard/src/components/input/decision-toast.tsx index 2bcd389..deb3a46 100644 --- a/packages/dashboard/src/components/input/decision-toast.tsx +++ b/packages/dashboard/src/components/input/decision-toast.tsx @@ -1,8 +1,9 @@ 'use client' import { cn } from '@/lib/utils' -import { HelpCircle, MessageSquare, X } from 'lucide-react' -import { useState } from 'react' +import { Check, HelpCircle, MessageSquare, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { MarkdownContent } from '@/components/ui/markdown-content' interface Question { question: string @@ -10,6 +11,7 @@ interface Question { label: string description?: string }> + multiSelect?: boolean } interface DecisionToastProps { @@ -35,15 +37,22 @@ export function DecisionToast({ }: DecisionToastProps) { const [showCustomInput, setShowCustomInput] = useState(false) const [customValue, setCustomValue] = useState('') + const [selectedOptions, setSelectedOptions] = useState([]) const currentQuestion = questions[currentIndex] + useEffect(() => { + setShowCustomInput(false) + setCustomValue('') + setSelectedOptions([]) + }, [currentIndex, currentQuestion?.question]) + // Show loading state when waiting for questions if (isLoading && !currentQuestion) { return (
@@ -83,10 +92,24 @@ export function DecisionToast({ } } + const isMultiSelect = !!currentQuestion?.multiSelect + + const toggleOption = (label: string) => { + setSelectedOptions((prev) => + prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label] + ) + } + + const handleMultiSelectSubmit = () => { + if (selectedOptions.length > 0) { + onAnswer(selectedOptions.join(', ')) + } + } + return (
@@ -96,9 +119,9 @@ export function DecisionToast({
{/* Toast content */} -
- {/* Header */} -
+
+ {/* Header - fixed */} +
Decision Required {questions.length > 1 && ( @@ -117,27 +140,66 @@ export function DecisionToast({ )}
- {/* Question text */} -

{currentQuestion.question}

+ {/* Question text - scrollable area */} +
+ +
+ {isMultiSelect && ( +
Select all that apply.
+ )} - {/* Option buttons */} + {/* Option buttons - fixed at bottom */} {!showCustomInput && ( - <> +
{currentQuestion.options.map((option, index) => ( ))}
+ {isMultiSelect && ( + + )} + {/* Custom answer link */} - +
)} - {/* Custom input */} + {/* Custom input - fixed at bottom */} {showCustomInput && ( -
+