From c393d60002a99d63e791e87090801d5cf28b8db3 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 17:33:38 +0100 Subject: [PATCH 1/3] fix: handle empty/greenfield projects in spec creation and prevent stuck planning state (#1426) Co-Authored-By: Claude Opus 4.6 --- apps/backend/prompts/spec_writer.md | 8 +++- apps/backend/spec/phases/spec_phases.py | 43 +++++++++++++++++++++- apps/backend/spec/pipeline/orchestrator.py | 35 ++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/backend/prompts/spec_writer.md b/apps/backend/prompts/spec_writer.md index bca7cca1bd..49c009b301 100644 --- a/apps/backend/prompts/spec_writer.md +++ b/apps/backend/prompts/spec_writer.md @@ -24,7 +24,7 @@ You MUST create `spec.md` with ALL required sections (see template below). ## PHASE 0: LOAD ALL CONTEXT (MANDATORY) ```bash -# Read all input files +# Read all input files (some may not exist for greenfield/empty projects) cat project_index.json cat requirements.json cat context.json @@ -35,6 +35,12 @@ Extract from these files: - **From requirements.json**: Task description, workflow type, services, acceptance criteria - **From context.json**: Files to modify, files to reference, patterns +**IMPORTANT**: If any input file is missing, empty, or shows 0 files, this is likely a **greenfield/new project**. Adapt accordingly: +- Skip sections that reference existing code (e.g., "Files to Modify", "Patterns to Follow") +- Instead, focus on files to CREATE and the initial project structure +- Define the tech stack, dependencies, and setup instructions from scratch +- Use industry best practices as patterns rather than referencing existing code + --- ## PHASE 1: ANALYZE CONTEXT diff --git a/apps/backend/spec/phases/spec_phases.py b/apps/backend/spec/phases/spec_phases.py index 1c39daf279..d483d8fd76 100644 --- a/apps/backend/spec/phases/spec_phases.py +++ b/apps/backend/spec/phases/spec_phases.py @@ -9,12 +9,36 @@ from typing import TYPE_CHECKING from .. import validator, writer +from ..discovery import get_project_index_stats from .models import MAX_RETRIES, PhaseResult if TYPE_CHECKING: pass +def _is_greenfield_project(spec_dir) -> bool: + """Check if the project is empty/greenfield (0 discovered files).""" + stats = get_project_index_stats(spec_dir) + return stats.get("file_count", 0) == 0 + + +def _greenfield_context() -> str: + """Return additional context for greenfield/empty projects.""" + return """ +**GREENFIELD PROJECT**: This is an empty or new project with no existing code. +There are no existing files to reference or modify. You are creating everything from scratch. + +Adapt your approach: +- Do NOT reference existing files, patterns, or code structures +- Focus on what needs to be CREATED, not modified +- Define the initial project structure, files, and directories +- Specify the tech stack, frameworks, and dependencies to install +- Provide setup instructions for the new project +- For "Files to Modify" and "Files to Reference" sections, list files to CREATE instead +- For "Patterns to Follow", describe industry best practices rather than existing code +""" + + class SpecPhaseMixin: """Mixin for spec writing and critique phase methods.""" @@ -29,6 +53,13 @@ async def phase_quick_spec(self) -> PhaseResult: "quick_spec", True, [str(spec_file), str(plan_file)], [], 0 ) + is_greenfield = _is_greenfield_project(self.spec_dir) + if is_greenfield: + self.ui.print_status( + "Greenfield project detected - adapting spec for new project", + "info", + ) + errors = [] for attempt in range(MAX_RETRIES): self.ui.print_status( @@ -42,7 +73,7 @@ async def phase_quick_spec(self) -> PhaseResult: This is a SIMPLE task. Create a minimal spec and implementation plan directly. No research or extensive analysis needed. - +{_greenfield_context() if is_greenfield else ""} Create: 1. A concise spec.md with just the essential sections 2. A simple implementation_plan.json with 1-2 subtasks @@ -80,6 +111,15 @@ async def phase_spec_writing(self) -> PhaseResult: "spec.md exists but has issues, regenerating...", "warning" ) + is_greenfield = _is_greenfield_project(self.spec_dir) + if is_greenfield: + self.ui.print_status( + "Greenfield project detected - adapting spec for new project", + "info", + ) + + greenfield_ctx = _greenfield_context() if is_greenfield else "" + errors = [] for attempt in range(MAX_RETRIES): self.ui.print_status( @@ -88,6 +128,7 @@ async def phase_spec_writing(self) -> PhaseResult: success, output = await self.run_agent_fn( "spec_writer.md", + additional_context=greenfield_ctx, phase_name="spec_writing", ) diff --git a/apps/backend/spec/pipeline/orchestrator.py b/apps/backend/spec/pipeline/orchestrator.py index e8dc040ef5..61820d2204 100644 --- a/apps/backend/spec/pipeline/orchestrator.py +++ b/apps/backend/spec/pipeline/orchestrator.py @@ -238,6 +238,41 @@ async def run(self, interactive: bool = True, auto_approve: bool = False) -> boo task_logger.start_phase(LogPhase.PLANNING, "Starting spec creation process") TaskEventEmitter.from_spec_dir(self.spec_dir).emit("PLANNING_STARTED") + # Track whether we've already ended the planning phase (to avoid double-end) + planning_phase_ended = False + + try: + return await self._run_phases(interactive, auto_approve, task_logger, ui) + except Exception as e: + # Ensure planning phase is always properly ended on unexpected errors + # This prevents the task from being stuck in "active" planning state + if not planning_phase_ended: + planning_phase_ended = True + task_logger.end_phase( + LogPhase.PLANNING, + success=False, + message=f"Spec creation failed with unexpected error: {e}", + ) + raise + + async def _run_phases( + self, + interactive: bool, + auto_approve: bool, + task_logger, + ui, + ) -> bool: + """Execute all spec creation phases. + + Args: + interactive: Whether to run in interactive mode + auto_approve: Whether to skip human review + task_logger: The task logger instance + ui: The UI module + + Returns: + True if spec creation and review completed successfully + """ print( box( f"Spec Directory: {self.spec_dir}\n" From 09858ddb77c4fef4e506bf88598c4e3ac2312598 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sun, 15 Feb 2026 22:19:33 +0100 Subject: [PATCH 2/3] fix: address PR review findings - planning_phase_ended bug, type hints, dedup (#1426) - Convert planning_phase_ended to instance attribute self._planning_phase_ended so _run_phases() can mark it True after each end_phase() call, preventing double-end on exception propagation - Add Path type annotation to _is_greenfield_project(spec_dir) - Extract duplicated greenfield detection into _check_and_log_greenfield() helper - Add TaskLogger and types.ModuleType type hints to _run_phases() signature - Simplify redundant SystemExit handler with explanatory comment Co-Authored-By: Claude Opus 4.6 --- apps/backend/spec/phases/spec_phases.py | 31 ++++++++++++---------- apps/backend/spec/pipeline/orchestrator.py | 22 +++++++++------ 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/apps/backend/spec/phases/spec_phases.py b/apps/backend/spec/phases/spec_phases.py index d483d8fd76..fd6378526d 100644 --- a/apps/backend/spec/phases/spec_phases.py +++ b/apps/backend/spec/phases/spec_phases.py @@ -6,6 +6,7 @@ """ import json +from pathlib import Path from typing import TYPE_CHECKING from .. import validator, writer @@ -16,7 +17,7 @@ pass -def _is_greenfield_project(spec_dir) -> bool: +def _is_greenfield_project(spec_dir: Path) -> bool: """Check if the project is empty/greenfield (0 discovered files).""" stats = get_project_index_stats(spec_dir) return stats.get("file_count", 0) == 0 @@ -42,6 +43,19 @@ def _greenfield_context() -> str: class SpecPhaseMixin: """Mixin for spec writing and critique phase methods.""" + def _check_and_log_greenfield(self) -> bool: + """Check if the project is greenfield and log if so. + + Returns: + True if the project is greenfield (no existing files). + """ + is_greenfield = _is_greenfield_project(self.spec_dir) + if is_greenfield: + self.ui.print_status( + "Greenfield project detected - adapting spec for new project", "info" + ) + return is_greenfield + async def phase_quick_spec(self) -> PhaseResult: """Quick spec for simple tasks - combines context and spec in one step.""" spec_file = self.spec_dir / "spec.md" @@ -53,12 +67,7 @@ async def phase_quick_spec(self) -> PhaseResult: "quick_spec", True, [str(spec_file), str(plan_file)], [], 0 ) - is_greenfield = _is_greenfield_project(self.spec_dir) - if is_greenfield: - self.ui.print_status( - "Greenfield project detected - adapting spec for new project", - "info", - ) + is_greenfield = self._check_and_log_greenfield() errors = [] for attempt in range(MAX_RETRIES): @@ -111,13 +120,7 @@ async def phase_spec_writing(self) -> PhaseResult: "spec.md exists but has issues, regenerating...", "warning" ) - is_greenfield = _is_greenfield_project(self.spec_dir) - if is_greenfield: - self.ui.print_status( - "Greenfield project detected - adapting spec for new project", - "info", - ) - + is_greenfield = self._check_and_log_greenfield() greenfield_ctx = _greenfield_context() if is_greenfield else "" errors = [] diff --git a/apps/backend/spec/pipeline/orchestrator.py b/apps/backend/spec/pipeline/orchestrator.py index 61820d2204..44b5bb08fa 100644 --- a/apps/backend/spec/pipeline/orchestrator.py +++ b/apps/backend/spec/pipeline/orchestrator.py @@ -6,6 +6,7 @@ """ import json +import types from collections.abc import Callable from pathlib import Path @@ -18,6 +19,7 @@ from task_logger import ( LogEntryType, LogPhase, + TaskLogger, get_task_logger, ) from ui import ( @@ -239,15 +241,15 @@ async def run(self, interactive: bool = True, auto_approve: bool = False) -> boo TaskEventEmitter.from_spec_dir(self.spec_dir).emit("PLANNING_STARTED") # Track whether we've already ended the planning phase (to avoid double-end) - planning_phase_ended = False + self._planning_phase_ended = False try: return await self._run_phases(interactive, auto_approve, task_logger, ui) except Exception as e: # Ensure planning phase is always properly ended on unexpected errors # This prevents the task from being stuck in "active" planning state - if not planning_phase_ended: - planning_phase_ended = True + if not self._planning_phase_ended: + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=False, @@ -259,8 +261,8 @@ async def _run_phases( self, interactive: bool, auto_approve: bool, - task_logger, - ui, + task_logger: TaskLogger, + ui: types.ModuleType, ) -> bool: """Execute all spec creation phases. @@ -326,6 +328,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult: results.append(result) if not result.success: print_status("Discovery failed", "error") + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=False, message="Discovery failed" ) @@ -340,6 +343,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult: results.append(result) if not result.success: print_status("Requirements gathering failed", "error") + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=False, @@ -370,6 +374,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult: results.append(result) if not result.success: print_status("Complexity assessment failed", "error") + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=False, message="Complexity assessment failed" ) @@ -431,6 +436,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult: f"Phase '{phase_name}' failed: {'; '.join(result.errors)}", LogEntryType.ERROR, ) + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=False, @@ -442,6 +448,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult: self._print_completion_summary(results, phases_executed) # End planning phase successfully + self._planning_phase_ended = True task_logger.end_phase( LogPhase.PLANNING, success=True, message="Spec creation complete" ) @@ -696,9 +703,8 @@ def _run_review_checkpoint(self, auto_approve: bool) -> bool: print_status("Build will not proceed without approval.", "warning") return False - except SystemExit as e: - if e.code != 0: - return False + except SystemExit: + # Review checkpoint may call sys.exit(); treat any exit as unapproved return False except KeyboardInterrupt: print() From cdd8c31097c5fe882fc75029c0dbb0d98ac9ccdb Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 16 Feb 2026 20:31:02 +0100 Subject: [PATCH 3/3] fix: prevent greenfield false positive on missing/corrupt project index When get_project_index_stats() returns {} (file missing, JSON parse error, or unrecognized format), _is_greenfield_project() now returns False instead of incorrectly classifying the project as greenfield. Also removes unused TYPE_CHECKING import and empty conditional block. --- apps/backend/spec/phases/spec_phases.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/backend/spec/phases/spec_phases.py b/apps/backend/spec/phases/spec_phases.py index fd6378526d..afb5e1a29e 100644 --- a/apps/backend/spec/phases/spec_phases.py +++ b/apps/backend/spec/phases/spec_phases.py @@ -7,19 +7,17 @@ import json from pathlib import Path -from typing import TYPE_CHECKING from .. import validator, writer from ..discovery import get_project_index_stats from .models import MAX_RETRIES, PhaseResult -if TYPE_CHECKING: - pass - def _is_greenfield_project(spec_dir: Path) -> bool: """Check if the project is empty/greenfield (0 discovered files).""" stats = get_project_index_stats(spec_dir) + if not stats: + return False # Can't determine - don't assume greenfield return stats.get("file_count", 0) == 0