diff --git a/.claude/templates/coding_prompt.template.md b/.claude/templates/coding_prompt.template.md index 832eb5996..5bdb2fe37 100644 --- a/.claude/templates/coding_prompt.template.md +++ b/.claude/templates/coding_prompt.template.md @@ -59,6 +59,8 @@ Use the feature_mark_in_progress tool with feature_id={your_assigned_id} If you get "already in-progress" error, that's OK - continue with implementation. +If the feature has a `maturity` tag (`alpha`, `limited_beta`, `beta`) or `spec_notes`, treat them as the spec review outcome: `alpha` means minimal scope (feature-flag it if the notes ask), `limited_beta` means respect the rollout guardrails in the notes, `beta` means the full quality bar. Never break the "must-not-break" areas listed in `spec_notes`. + Focus on completing one feature perfectly in this session. It's ok if you only complete one feature, as more sessions will follow. #### When to Skip a Feature (EXTREMELY RARE) diff --git a/api/database.py b/api/database.py index 523ea2243..bd1827d2d 100644 --- a/api/database.py +++ b/api/database.py @@ -66,6 +66,14 @@ class Feature(Base): human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request human_input_response = Column(JSON, nullable=True, default=None) # Human's response + # Backlog / spec roadmapping + # backlog=True: not committed / not ready - never handed to coding agents + backlog = Column(Boolean, nullable=False, default=False, index=True) + # Maturity tag from spec review: "alpha" | "limited_beta" | "beta" (NULL = not reviewed) + maturity = Column(String(20), nullable=True, default=None) + # Spec review notes: rollout constraints, risks, must-not-break areas + spec_notes = Column(Text, nullable=True, default=None) + def to_dict(self) -> dict: """Convert feature to dictionary for JSON serialization.""" return { @@ -84,6 +92,10 @@ def to_dict(self) -> dict: "needs_human_input": self.needs_human_input if self.needs_human_input is not None else False, "human_input_request": self.human_input_request, "human_input_response": self.human_input_response, + # Backlog / spec roadmapping fields + "backlog": self.backlog if self.backlog is not None else False, + "maturity": self.maturity, + "spec_notes": self.spec_notes, } def get_dependencies_safe(self) -> list[int]: @@ -326,6 +338,22 @@ def _migrate_add_human_input_columns(engine) -> None: conn.commit() +def _migrate_add_roadmap_columns(engine) -> None: + """Add backlog / maturity / spec_notes columns to existing databases.""" + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(features)")) + columns = [row[1] for row in result.fetchall()] + + if "backlog" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN backlog BOOLEAN DEFAULT 0")) + if "maturity" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN maturity VARCHAR(20) DEFAULT NULL")) + if "spec_notes" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN spec_notes TEXT DEFAULT NULL")) + conn.execute(text("UPDATE features SET backlog = 0 WHERE backlog IS NULL")) + conn.commit() + + def _migrate_add_schedules_tables(engine) -> None: """Create schedules and schedule_overrides tables if they don't exist.""" from sqlalchemy import inspect @@ -450,6 +478,7 @@ def create_database(project_dir: Path) -> tuple: _migrate_add_dependencies_column(engine) _migrate_add_testing_columns(engine) _migrate_add_human_input_columns(engine) + _migrate_add_roadmap_columns(engine) # Migrate to add schedules tables _migrate_add_schedules_tables(engine) diff --git a/api/dependency_resolver.py b/api/dependency_resolver.py index 9cc8082d3..2af13c0ac 100644 --- a/api/dependency_resolver.py +++ b/api/dependency_resolver.py @@ -10,6 +10,8 @@ from collections import deque from typing import TypedDict +from api.roadmap import DEFAULT_MATURITY_ORDER, ready_sort_key + # Security: Prevent DoS via excessive dependencies MAX_DEPENDENCIES_PER_FEATURE = 20 MAX_DEPENDENCY_DEPTH = 50 # Prevent stack overflow in cycle detection @@ -352,34 +354,40 @@ def compute_scheduling_scores(features: list[dict]) -> dict[int, float]: return scores -def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]: +def get_ready_features( + features: list[dict], + limit: int = 10, + maturity_order: list[str] | tuple[str, ...] = DEFAULT_MATURITY_ORDER, +) -> list[dict]: """Get features that are ready to be worked on. A feature is ready if: - It is not passing - It is not in progress + - It is not in the Backlog - All its dependencies are satisfied Args: features: List of all feature dicts limit: Maximum number of features to return + maturity_order: Maturity tags in scheduling order (first = highest) Returns: - List of ready features, sorted by priority + List of ready features, sorted by maturity tag then scheduling score """ passing_ids = {f["id"] for f in features if f.get("passes")} ready = [] for f in features: - if f.get("passes") or f.get("in_progress"): + if f.get("passes") or f.get("in_progress") or f.get("backlog"): continue deps = f.get("dependencies") or [] if all(dep_id in passing_ids for dep_id in deps): ready.append(f) - # Sort by scheduling score (higher = first), then priority, then id + # Sort by maturity tag, then scheduling score (higher = first), then priority, then id scores = compute_scheduling_scores(features) - ready.sort(key=lambda f: (-scores.get(f["id"], 0), f.get("priority", 999), f["id"])) + ready.sort(key=ready_sort_key(scores, maturity_order)) return ready[:limit] diff --git a/api/roadmap.py b/api/roadmap.py new file mode 100644 index 000000000..de94feb14 --- /dev/null +++ b/api/roadmap.py @@ -0,0 +1,159 @@ +""" +Backlog and Spec Roadmapping +============================ + +Shared helpers for the Backlog state and maturity tags (alpha / limited beta / +beta) used by the REST API, the MCP server and the parallel orchestrator. + +- Backlog features are "not committed / not ready" and are never handed to + the coding agents. A human promotes them to Pending once the ready checks + pass (or explicitly overrides the checks). +- Maturity tags influence scheduling: among build-ready features, the + orchestrator picks tags in the configured order first (default + beta > limited_beta > alpha), then falls back to the dependency-aware + scheduling score. Untagged features sort after all tagged ones. +""" + +from typing import Any, Callable + +MATURITY_LEVELS: tuple[str, ...] = ("alpha", "limited_beta", "beta") +MATURITY_LABELS: dict[str, str] = { + "alpha": "Alpha", + "limited_beta": "Limited Beta", + "beta": "Beta", +} +DEFAULT_MATURITY_ORDER: tuple[str, ...] = ("beta", "limited_beta", "alpha") + +# Global settings key (registry) holding the comma-separated maturity order +MATURITY_ORDER_SETTING = "maturity_order" + + +def normalize_maturity(value: str | None) -> str | None: + """Normalize a maturity tag ("Limited Beta", "limited-beta" -> "limited_beta"). + + Returns None for empty values. Raises ValueError for unknown tags. + """ + if value is None: + return None + normalized = value.strip().lower().replace("-", "_").replace(" ", "_") + if not normalized: + return None + if normalized not in MATURITY_LEVELS: + raise ValueError(f"Invalid maturity tag '{value}'. Must be one of: {', '.join(MATURITY_LEVELS)}") + return normalized + + +def parse_maturity_order(value: str | None) -> list[str]: + """Parse a comma-separated maturity order, falling back to the default. + + Unknown or duplicate entries are dropped; missing levels are appended in + default order so every tag always has a rank. + """ + order: list[str] = [] + for part in (value or "").split(","): + try: + tag = normalize_maturity(part) + except ValueError: + continue + if tag and tag not in order: + order.append(tag) + if not order: + return list(DEFAULT_MATURITY_ORDER) + for tag in DEFAULT_MATURITY_ORDER: + if tag not in order: + order.append(tag) + return order + + +def get_configured_maturity_order() -> list[str]: + """Read the maturity order from global settings (registry).""" + try: + from registry import get_setting + return parse_maturity_order(get_setting(MATURITY_ORDER_SETTING)) + except Exception: + return list(DEFAULT_MATURITY_ORDER) + + +def maturity_rank(maturity: str | None, order: list[str] | tuple[str, ...]) -> int: + """Rank of a maturity tag (lower = scheduled first). Untagged sorts last.""" + if maturity is not None and maturity in order: + return list(order).index(maturity) + return len(order) + + +def ready_sort_key( + scores: dict[int, float], + order: list[str] | tuple[str, ...] = DEFAULT_MATURITY_ORDER, +) -> Callable[[dict[str, Any]], tuple]: + """Sort key for build-ready features. + + Maturity rank first (configured order, untagged last), then the + dependency-aware scheduling score (higher first), then priority, then id. + """ + def key(f: dict[str, Any]) -> tuple: + return ( + maturity_rank(f.get("maturity"), order), + -scores.get(f["id"], 0), + f.get("priority", 999), + f["id"], + ) + return key + + +def is_backlog(feature: dict[str, Any]) -> bool: + """True when a feature dict is in the Backlog state.""" + return bool(feature.get("backlog")) + + +def compute_readiness(feature: dict[str, Any], features_by_id: dict[int, dict[str, Any]]) -> list[dict[str, Any]]: + """Evaluate the "ready" checks gating promotion from Backlog to Pending. + + Args: + feature: Feature dict (as produced by Feature.to_dict()). + features_by_id: All features keyed by id, used to validate dependencies. + + Returns: + List of {"id", "label", "passed", "detail"} dicts. + """ + description = (feature.get("description") or "").strip() + steps = [s for s in (feature.get("steps") or []) if isinstance(s, str) and s.strip()] + deps = feature.get("dependencies") or [] + missing_deps = [d for d in deps if d not in features_by_id] + backlog_deps = [d for d in deps if d in features_by_id and is_backlog(features_by_id[d])] + + checks = [ + { + "id": "description", + "label": "Requirements described", + "passed": len(description) >= 20, + "detail": None if len(description) >= 20 else "Description must be at least 20 characters", + }, + { + "id": "acceptance_criteria", + "label": "Acceptance criteria / test steps defined", + "passed": len(steps) > 0, + "detail": None if steps else "Add at least one test step", + }, + { + "id": "maturity", + "label": "Spec reviewed (maturity tag assigned)", + "passed": bool(feature.get("maturity")), + "detail": None if feature.get("maturity") else "Assign alpha, limited beta or beta", + }, + { + "id": "dependencies", + "label": "Dependencies committed (none in Backlog)", + "passed": not missing_deps and not backlog_deps, + "detail": ( + f"Missing dependencies: {missing_deps}" if missing_deps + else f"Dependencies still in Backlog: {backlog_deps}" if backlog_deps + else None + ), + }, + ] + return checks + + +def failed_checks(checks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return only the checks that did not pass.""" + return [c for c in checks if not c["passed"]] diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 71e217d41..2926509a2 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -48,6 +48,7 @@ would_create_circular_dependency, ) from api.migration import migrate_json_to_sqlite +from api.roadmap import get_configured_maturity_order, ready_sort_key # Configuration from environment PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve() @@ -405,11 +406,13 @@ def feature_mark_in_progress( """ session = get_session() try: - # Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input + # Atomic claim: only succeeds if feature is not already claimed, passing, + # blocked for human input, or parked in the Backlog result = session.execute(text(""" UPDATE features SET in_progress = 1 WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0 + AND COALESCE(backlog, 0) = 0 """), {"id": feature_id}) session.commit() @@ -424,6 +427,8 @@ def feature_mark_in_progress( return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"}) if getattr(feature, 'needs_human_input', False): return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"}) + if feature.backlog: + return json.dumps({"error": f"Feature with ID {feature_id} is in the Backlog (not ready to build)"}) return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"}) # Fetch the claimed feature @@ -464,11 +469,16 @@ def feature_claim_and_get( if getattr(feature, 'needs_human_input', False): return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"}) - # Try atomic claim: only succeeds if not already claimed and not blocked for human input + if feature.backlog: + return json.dumps({"error": f"Feature with ID {feature_id} is in the Backlog (not ready to build)"}) + + # Try atomic claim: only succeeds if not already claimed, not blocked for human input, + # and not in the Backlog result = session.execute(text(""" UPDATE features SET in_progress = 1 WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0 + AND COALESCE(backlog, 0) = 0 """), {"id": feature_id}) session.commit() @@ -797,7 +807,9 @@ def feature_get_ready( """Get all features ready to start (dependencies satisfied, not in progress). Useful for parallel execution - returns multiple features that can run simultaneously. - A feature is ready if it is not passing, not in progress, and all dependencies are passing. + A feature is ready if it is not passing, not in progress, not in the Backlog, + and all dependencies are passing. Results are ordered by maturity tag + (configured order, default beta > limited_beta > alpha), then scheduling score. Args: limit: Maximum number of features to return (1-50, default 10) @@ -817,13 +829,15 @@ def feature_get_ready( continue if getattr(f, 'needs_human_input', False): continue + if f.backlog: + continue # Backlog = not committed / not ready deps = f.dependencies or [] if all(dep_id in passing_ids for dep_id in deps): ready.append(f.to_dict()) - # Sort by scheduling score (higher = first), then priority, then id + # Sort by maturity tag, then scheduling score (higher = first), then priority, then id scores = compute_scheduling_scores(all_dicts) - ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"])) + ready.sort(key=ready_sort_key(scores, get_configured_maturity_order())) return json.dumps({ "features": ready[:limit], diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py index e39ef7995..aacc526fe 100644 --- a/parallel_orchestrator.py +++ b/parallel_orchestrator.py @@ -35,6 +35,7 @@ from api.database import Feature, create_database from api.dependency_resolver import are_dependencies_satisfied, compute_scheduling_scores +from api.roadmap import get_configured_maturity_order, ready_sort_key from progress import has_features from server.utils.process_utils import kill_process_tree @@ -180,6 +181,8 @@ def __init__( self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3 self.testing_batch_size = min(max(testing_batch_size, 1), 15) # Clamp 1-15 self.batch_size = min(max(batch_size, 1), 15) # Clamp 1-15 + # Maturity tag scheduling order (global setting, read once per run) + self.maturity_order = get_configured_maturity_order() self.on_output = on_output self.on_status = on_status @@ -425,7 +428,7 @@ def build_feature_batches( for cid in candidate_ids: cf = feature_map.get(cid) - if not cf or cf.get("passes") or cf.get("in_progress"): + if not cf or cf.get("passes") or cf.get("in_progress") or cf.get("backlog"): continue # Check if ALL deps are satisfied by simulated passing set deps = cf.get("dependencies") or [] @@ -496,6 +499,9 @@ def get_resumable_features( for fd in feature_dicts: if not fd.get("in_progress") or fd.get("passes"): continue + # Never resume Backlog features (not committed / not ready) + if fd.get("backlog"): + continue # Skip if blocked for human input if fd.get("needs_human_input"): continue @@ -507,10 +513,10 @@ def get_resumable_features( continue resumable.append(fd) - # Sort by scheduling score (higher = first), then priority, then id + # Sort by maturity tag, then scheduling score (higher = first), then priority, then id if scheduling_scores is None: scheduling_scores = compute_scheduling_scores(feature_dicts) - resumable.sort(key=lambda f: (-scheduling_scores.get(f["id"], 0), f["priority"], f["id"])) + resumable.sort(key=ready_sort_key(scheduling_scores, self.maturity_order)) return resumable def get_ready_features( @@ -543,11 +549,18 @@ def get_ready_features( running_ids.update(batch_ids) ready = [] - skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0} + skipped_reasons = { + "passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, + "needs_human_input": 0, "backlog": 0, + } for fd in feature_dicts: if fd.get("passes"): skipped_reasons["passes"] += 1 continue + # Guardrail: only Pending work is build-ready, never Backlog + if fd.get("backlog"): + skipped_reasons["backlog"] += 1 + continue if fd.get("needs_human_input"): skipped_reasons["needs_human_input"] += 1 continue @@ -568,10 +581,10 @@ def get_ready_features( else: skipped_reasons["deps"] += 1 - # Sort by scheduling score (higher = first), then priority, then id + # Sort by maturity tag, then scheduling score (higher = first), then priority, then id if scheduling_scores is None: scheduling_scores = compute_scheduling_scores(feature_dicts) - ready.sort(key=lambda f: (-scheduling_scores.get(f["id"], 0), f["priority"], f["id"])) + ready.sort(key=ready_sort_key(scheduling_scores, self.maturity_order)) # Summary counts for logging passing = skipped_reasons["passes"] @@ -616,6 +629,8 @@ def get_all_complete(self, feature_dicts: list[dict] | None = None) -> bool: if fd.get("passes"): passing_count += 1 continue # Completed successfully + if fd.get("backlog"): + continue # Not committed - doesn't keep the run alive if self._failure_counts.get(fd["id"], 0) >= MAX_FEATURE_RETRIES: failed_count += 1 continue # Permanently failed, count as "done" @@ -731,6 +746,8 @@ def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, st return False, "Feature not found" if feature.passes: return False, "Feature already complete" + if feature.backlog: + return False, "Feature is in the Backlog (not ready)" if resume: # Resuming: feature should already be in_progress @@ -793,6 +810,8 @@ def start_feature_batch(self, feature_ids: list[int], resume: bool = False) -> t return False, f"Feature {fid} not found" if feature.passes: return False, f"Feature {fid} already complete" + if feature.backlog: + return False, f"Feature {fid} is in the Backlog (not ready)" if not resume: if feature.in_progress: return False, f"Feature {fid} already in progress" diff --git a/server/routers/features.py b/server/routers/features.py index a844db76f..e0e3c47b7 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -24,6 +24,9 @@ FeatureResponse, FeatureUpdate, HumanInputResponse, + PromoteRequest, + ReadinessCheck, + SpecReviewRequest, ) from ..utils.project_helpers import get_project_path as _get_project_path from ..utils.validation import validate_project_name @@ -71,15 +74,36 @@ def get_db_session(project_dir: Path): session.close() -def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureResponse: +def _get_roadmap(): + """Lazy import of backlog / maturity helpers.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from api import roadmap + return roadmap + + +def _features_by_id(session, Feature) -> dict[int, dict]: + """Snapshot all features as dicts keyed by id (for readiness checks).""" + return {f.id: f.to_dict() for f in session.query(Feature).all()} + + +def feature_to_response( + f, + passing_ids: set[int] | None = None, + features_by_id: dict[int, dict] | None = None, +) -> FeatureResponse: """Convert a Feature model to a FeatureResponse. Handles legacy NULL values in boolean fields by treating them as False. - Computes blocked status if passing_ids is provided. + Computes blocked status if passing_ids is provided, and the Backlog -> + Pending ready checks if features_by_id is provided. Args: f: Feature model instance passing_ids: Optional set of feature IDs that are passing (for computing blocked status) + features_by_id: Optional snapshot of all features (for computing ready checks) Returns: FeatureResponse with computed blocked status @@ -92,6 +116,11 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon blocking = [d for d in deps if d not in passing_ids] blocked = len(blocking) > 0 + readiness: list[ReadinessCheck] = [] + if features_by_id is not None: + checks = _get_roadmap().compute_readiness(f.to_dict(), features_by_id) + readiness = [ReadinessCheck(**c) for c in checks] + return FeatureResponse( id=f.id, priority=f.priority, @@ -108,6 +137,11 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon needs_human_input=getattr(f, 'needs_human_input', False) or False, human_input_request=getattr(f, 'human_input_request', None), human_input_response=getattr(f, 'human_input_response', None), + backlog=bool(getattr(f, 'backlog', False)), + maturity=getattr(f, 'maturity', None), + spec_notes=getattr(f, 'spec_notes', None), + readiness=readiness, + ready=bool(readiness) and all(c.passed for c in readiness), ) @@ -116,9 +150,11 @@ async def list_features(project_name: str): """ List all features for a project organized by status. - Returns features in three lists: + Returns features in these lists: + - backlog: not committed / not ready (excluded from the agent queue) - pending: passes=False, not currently being worked on - in_progress: features currently being worked on (tracked via agent output) + - needs_human_input: agent is waiting for a human response - done: passes=True """ project_name = validate_project_name(project_name) @@ -143,16 +179,20 @@ async def list_features(project_name: str): # Compute passing IDs for blocked status calculation passing_ids = {f.id for f in all_features if f.passes} + features_by_id = {f.id: f.to_dict() for f in all_features} + backlog = [] pending = [] in_progress = [] done = [] needs_human_input_list = [] for f in all_features: - feature_response = feature_to_response(f, passing_ids) + feature_response = feature_to_response(f, passing_ids, features_by_id) if f.passes: done.append(feature_response) + elif getattr(f, 'backlog', False): + backlog.append(feature_response) elif getattr(f, 'needs_human_input', False): needs_human_input_list.append(feature_response) elif f.in_progress: @@ -165,6 +205,7 @@ async def list_features(project_name: str): in_progress=in_progress, done=done, needs_human_input=needs_human_input_list, + backlog=backlog, ) except HTTPException: raise @@ -206,13 +247,16 @@ async def create_feature(project_name: str, feature: FeatureCreate): dependencies=feature.dependencies if feature.dependencies else None, passes=False, in_progress=False, + backlog=feature.backlog, + maturity=feature.maturity, + spec_notes=feature.spec_notes, ) session.add(db_feature) session.commit() session.refresh(db_feature) - return feature_to_response(db_feature) + return feature_to_response(db_feature, features_by_id=_features_by_id(session, Feature)) except HTTPException: raise except Exception: @@ -288,6 +332,9 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): dependencies=feature_data.dependencies if feature_data.dependencies else None, passes=False, in_progress=False, + backlog=feature_data.backlog, + maturity=feature_data.maturity, + spec_notes=feature_data.spec_notes, ) session.add(db_feature) session.flush() # Flush to get the ID immediately @@ -349,9 +396,11 @@ async def get_dependency_graph(project_name: str): deps = f.dependencies or [] blocking = [d for d in deps if d not in passing_ids] - status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"] + status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input", "backlog"] if f.passes: status = "done" + elif getattr(f, 'backlog', False): + status = "backlog" elif getattr(f, 'needs_human_input', False): status = "needs_human_input" elif blocking: @@ -367,7 +416,8 @@ async def get_dependency_graph(project_name: str): category=f.category, status=status, priority=f.priority, - dependencies=deps + dependencies=deps, + maturity=getattr(f, 'maturity', None), )) for dep_id in deps: @@ -412,7 +462,9 @@ async def get_feature(project_name: str, feature_id: int): if not feature: raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") - return feature_to_response(feature) + features_by_id = _features_by_id(session, Feature) + passing_ids = {fid for fid, fd in features_by_id.items() if fd["passes"]} + return feature_to_response(feature, passing_ids, features_by_id) except HTTPException: raise except Exception: @@ -467,15 +519,20 @@ async def update_feature(project_name: str, feature_id: int, update: FeatureUpda feature.priority = update.priority if update.dependencies is not None: feature.dependencies = update.dependencies if update.dependencies else None + # maturity / spec_notes can be cleared by explicitly sending null + if "maturity" in update.model_fields_set: + feature.maturity = update.maturity + if "spec_notes" in update.model_fields_set: + feature.spec_notes = update.spec_notes or None session.commit() session.refresh(feature) # Compute passing IDs for response - all_features = session.query(Feature).all() - passing_ids = {f.id for f in all_features if f.passes} + features_by_id = _features_by_id(session, Feature) + passing_ids = {fid for fid, fd in features_by_id.items() if fd["passes"]} - return feature_to_response(feature, passing_ids) + return feature_to_response(feature, passing_ids, features_by_id) except HTTPException: raise except Exception: @@ -574,6 +631,147 @@ async def skip_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Failed to skip feature") +# ============================================================================ +# Backlog / Spec Roadmapping Endpoints +# ============================================================================ + + +def _resolve_project_dir(project_name: str) -> Path: + """Validate the project name and return its existing directory.""" + project_name = validate_project_name(project_name) + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + return project_dir + + +@router.post("/{feature_id}/spec-review", response_model=FeatureResponse) +async def review_feature_spec(project_name: str, feature_id: int, review: SpecReviewRequest): + """Spec Review step: assign a maturity tag (alpha / limited_beta / beta) and notes. + + The maturity tag drives agent prioritization among build-ready features + (configurable order, default beta > limited_beta > alpha). Notes capture + rollout constraints, risks, must-not-break areas and are shown to the agent. + """ + project_dir = _resolve_project_dir(project_name) + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + + feature.maturity = review.maturity + if "notes" in review.model_fields_set: + feature.spec_notes = review.notes or None + session.commit() + session.refresh(feature) + + features_by_id = _features_by_id(session, Feature) + passing_ids = {fid for fid, fd in features_by_id.items() if fd["passes"]} + return feature_to_response(feature, passing_ids, features_by_id) + except HTTPException: + raise + except Exception: + logger.exception("Failed to review feature spec") + raise HTTPException(status_code=500, detail="Failed to review feature spec") + + +@router.post("/{feature_id}/backlog", response_model=FeatureResponse) +async def move_feature_to_backlog(project_name: str, feature_id: int): + """Move a feature to the Backlog (not committed / not ready). + + Backlog features are excluded from the agent queue. Completed features and + features an agent is actively working on cannot be moved. + """ + project_dir = _resolve_project_dir(project_name) + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + if feature.passes: + raise HTTPException(status_code=400, detail="Cannot move a completed feature to the Backlog") + if feature.in_progress: + raise HTTPException( + status_code=409, + detail="Feature is in progress. Stop the agent working on it before moving it to the Backlog.", + ) + + feature.backlog = True + session.commit() + session.refresh(feature) + + features_by_id = _features_by_id(session, Feature) + passing_ids = {fid for fid, fd in features_by_id.items() if fd["passes"]} + return feature_to_response(feature, passing_ids, features_by_id) + except HTTPException: + raise + except Exception: + logger.exception("Failed to move feature to backlog") + raise HTTPException(status_code=500, detail="Failed to move feature to backlog") + + +@router.post("/{feature_id}/promote", response_model=FeatureResponse) +async def promote_feature(project_name: str, feature_id: int, request: PromoteRequest | None = None): + """Promote a feature from Backlog to Pending (build-ready). + + Enforces the ready checks (requirements described, acceptance criteria + defined, maturity tag assigned, no dependencies left in the Backlog). + Returns 409 with the failing checks unless ``force`` is true, which is the + explicit human override. + """ + project_dir = _resolve_project_dir(project_name) + force = bool(request and request.force) + roadmap = _get_roadmap() + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + if not feature.backlog: + raise HTTPException(status_code=400, detail="Feature is not in the Backlog") + + features_by_id = _features_by_id(session, Feature) + failed = roadmap.failed_checks(roadmap.compute_readiness(feature.to_dict(), features_by_id)) + if failed and not force: + raise HTTPException( + status_code=409, + detail={ + "message": "Feature is not ready to move to Pending", + "failed_checks": failed, + }, + ) + if failed: + logger.info( + "Feature %s promoted to Pending with failing ready checks (human override): %s", + feature_id, [c["id"] for c in failed], + ) + + feature.backlog = False + session.commit() + session.refresh(feature) + + features_by_id = _features_by_id(session, Feature) + passing_ids = {fid for fid, fd in features_by_id.items() if fd["passes"]} + return feature_to_response(feature, passing_ids, features_by_id) + except HTTPException: + raise + except Exception: + logger.exception("Failed to promote feature") + raise HTTPException(status_code=500, detail="Failed to promote feature") + + @router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse) async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse): """Resolve a human input request for a feature. diff --git a/server/routers/settings.py b/server/routers/settings.py index 9ff04acde..5a3ef739e 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -21,6 +21,7 @@ if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) +from api.roadmap import MATURITY_ORDER_SETTING, parse_maturity_order from registry import ( API_PROVIDERS, AVAILABLE_MODELS, @@ -122,6 +123,7 @@ async def get_settings(): api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), api_model=all_settings.get("api_model"), + maturity_order=parse_maturity_order(all_settings.get(MATURITY_ORDER_SETTING)), ) @@ -174,6 +176,9 @@ async def update_settings(update: SettingsUpdate): if update.api_model is not None: set_setting("api_model", update.api_model) + if update.maturity_order is not None: + set_setting(MATURITY_ORDER_SETTING, ",".join(update.maturity_order)) + # Return updated settings all_settings = get_all_settings() api_provider = all_settings.get("api_provider", "claude") @@ -194,4 +199,5 @@ async def update_settings(update: SettingsUpdate): api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), api_model=all_settings.get("api_model"), + maturity_order=parse_maturity_order(all_settings.get(MATURITY_ORDER_SETTING)), ) diff --git a/server/schemas.py b/server/schemas.py index d54f00aa9..48429aa34 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -18,8 +18,19 @@ if str(_root) not in sys.path: sys.path.insert(0, str(_root)) +from api.roadmap import DEFAULT_MATURITY_ORDER, MATURITY_LEVELS, normalize_maturity from registry import DEFAULT_MODEL, LEGACY_MODEL_MAP, VALID_MODELS +# Maturity tag assigned during spec review (see api/roadmap.py) +Maturity = Literal["alpha", "limited_beta", "beta"] + + +def _normalize_maturity_value(v): + """Accept "Limited Beta" / "limited-beta" style input for maturity tags.""" + if isinstance(v, str): + return normalize_maturity(v) + return v + # ============================================================================ # Project Schemas # ============================================================================ @@ -108,21 +119,49 @@ class FeatureBase(BaseModel): description: str steps: list[str] dependencies: list[int] = Field(default_factory=list) # Optional dependencies + maturity: Maturity | None = None # Spec review maturity tag + spec_notes: str | None = Field(None, max_length=5000) # Rollout constraints, risks, etc. + + @field_validator('maturity', mode='before') + @classmethod + def normalize_maturity_tag(cls, v): + return _normalize_maturity_value(v) class FeatureCreate(FeatureBase): """Request schema for creating a new feature.""" priority: int | None = None + backlog: bool = False # True = park in Backlog (not ready, excluded from agent queue) class FeatureUpdate(BaseModel): - """Request schema for updating a feature (partial updates allowed).""" + """Request schema for updating a feature (partial updates allowed). + + maturity / spec_notes may be explicitly set to null to clear them. + Backlog state changes go through the /backlog and /promote endpoints so + ready checks are always enforced. + """ category: str | None = None name: str | None = None description: str | None = None steps: list[str] | None = None priority: int | None = None dependencies: list[int] | None = None # Optional - can update dependencies + maturity: Maturity | None = None + spec_notes: str | None = Field(None, max_length=5000) + + @field_validator('maturity', mode='before') + @classmethod + def normalize_maturity_tag(cls, v): + return _normalize_maturity_value(v) + + +class ReadinessCheck(BaseModel): + """A single "ready" check gating promotion from Backlog to Pending.""" + id: str + label: str + passed: bool + detail: str | None = None class FeatureResponse(FeatureBase): @@ -136,6 +175,9 @@ class FeatureResponse(FeatureBase): needs_human_input: bool = False human_input_request: dict | None = None human_input_response: dict | None = None + backlog: bool = False # Not committed / not ready - excluded from the agent queue + readiness: list[ReadinessCheck] = Field(default_factory=list) # Computed ready checks + ready: bool = False # Computed: all ready checks pass class Config: from_attributes = True @@ -168,6 +210,23 @@ class FeatureListResponse(BaseModel): in_progress: list[FeatureResponse] done: list[FeatureResponse] needs_human_input: list[FeatureResponse] = Field(default_factory=list) + backlog: list[FeatureResponse] = Field(default_factory=list) + + +class SpecReviewRequest(BaseModel): + """Request schema for the Spec Review step (assigns a maturity tag).""" + maturity: Maturity + notes: str | None = Field(None, max_length=5000) + + @field_validator('maturity', mode='before') + @classmethod + def normalize_maturity_tag(cls, v): + return _normalize_maturity_value(v) + + +class PromoteRequest(BaseModel): + """Request schema for promoting a feature from Backlog to Pending.""" + force: bool = False # Human override: promote even if ready checks fail class FeatureBulkCreate(BaseModel): @@ -191,9 +250,10 @@ class DependencyGraphNode(BaseModel): id: int name: str category: str - status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"] + status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input", "backlog"] priority: int dependencies: list[int] + maturity: Maturity | None = None class DependencyGraphEdge(BaseModel): @@ -489,6 +549,7 @@ class SettingsResponse(BaseModel): api_base_url: str | None = None api_has_auth_token: bool = False # Never expose actual token api_model: str | None = None + maturity_order: list[str] = Field(default_factory=lambda: list(DEFAULT_MATURITY_ORDER)) # First = highest class ModelsResponse(BaseModel): @@ -510,6 +571,21 @@ class SettingsUpdate(BaseModel): api_base_url: str | None = Field(None, max_length=500) api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned api_model: str | None = Field(None, max_length=200) + maturity_order: list[Maturity] | None = None # Scheduling order, first = highest + + @field_validator('maturity_order', mode='before') + @classmethod + def normalize_maturity_order(cls, v): + if isinstance(v, list): + return [_normalize_maturity_value(item) for item in v] + return v + + @field_validator('maturity_order') + @classmethod + def validate_maturity_order(cls, v: list[str] | None) -> list[str] | None: + if v is not None and sorted(v) != sorted(MATURITY_LEVELS): + raise ValueError(f"maturity_order must list each of {list(MATURITY_LEVELS)} exactly once") + return v @field_validator('api_base_url') @classmethod diff --git a/test_roadmap.py b/test_roadmap.py new file mode 100644 index 000000000..f201d9673 --- /dev/null +++ b/test_roadmap.py @@ -0,0 +1,316 @@ +""" +Tests for the Backlog state and Iterative Spec Roadmapping (maturity tags). + +Covers: +- api/roadmap.py helpers (tag normalization, ordering, ready checks) +- Agent queue exclusion and maturity-based prioritization +- Database migration of the new columns +- REST endpoints: create, spec review, backlog, promote (with ready checks) +- MCP claim guardrail (Backlog features can never be claimed) +""" + +import asyncio +import json +import sqlite3 +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from api.dependency_resolver import compute_scheduling_scores, get_ready_features +from api.roadmap import ( + DEFAULT_MATURITY_ORDER, + compute_readiness, + failed_checks, + maturity_rank, + normalize_maturity, + parse_maturity_order, + ready_sort_key, +) + + +def _feature(fid, maturity=None, backlog=False, priority=1, deps=None, passes=False, in_progress=False): + return { + "id": fid, + "priority": priority, + "category": "core", + "name": f"Feature {fid}", + "description": "A sufficiently detailed description of the feature", + "steps": ["Open the page", "Verify the result"], + "passes": passes, + "in_progress": in_progress, + "dependencies": deps or [], + "backlog": backlog, + "maturity": maturity, + } + + +# ============================================================================= +# Helpers +# ============================================================================= + + +class TestMaturityHelpers: + def test_normalize_maturity(self): + assert normalize_maturity("Limited Beta") == "limited_beta" + assert normalize_maturity("limited-beta") == "limited_beta" + assert normalize_maturity(" BETA ") == "beta" + assert normalize_maturity("") is None + assert normalize_maturity(None) is None + with pytest.raises(ValueError): + normalize_maturity("gamma") + + def test_parse_maturity_order(self): + assert parse_maturity_order(None) == list(DEFAULT_MATURITY_ORDER) + assert parse_maturity_order("") == list(DEFAULT_MATURITY_ORDER) + assert parse_maturity_order("alpha,beta,limited_beta") == ["alpha", "beta", "limited_beta"] + # Unknown/duplicate entries dropped, missing levels appended in default order + assert parse_maturity_order("alpha,bogus,alpha") == ["alpha", "beta", "limited_beta"] + + def test_maturity_rank_untagged_last(self): + order = ["beta", "limited_beta", "alpha"] + assert maturity_rank("beta", order) == 0 + assert maturity_rank("alpha", order) == 2 + assert maturity_rank(None, order) == 3 + + +class TestReadinessChecks: + def test_ready_feature_passes_all_checks(self): + f = _feature(1, maturity="beta", backlog=True) + checks = compute_readiness(f, {1: f}) + assert failed_checks(checks) == [] + + def test_unready_feature_reports_each_failure(self): + dep = _feature(2, backlog=True) + f = { + **_feature(1, backlog=True, deps=[2, 99]), + "description": "short", + "steps": [" "], + "maturity": None, + } + failed = {c["id"] for c in failed_checks(compute_readiness(f, {1: f, 2: dep}))} + assert failed == {"description", "acceptance_criteria", "maturity", "dependencies"} + + +# ============================================================================= +# Agent queue: exclusion + prioritization +# ============================================================================= + + +class TestAgentQueue: + def test_backlog_excluded_from_ready(self): + features = [_feature(1, maturity="beta", backlog=True), _feature(2)] + ready_ids = [f["id"] for f in get_ready_features(features)] + assert ready_ids == [2] + + def test_maturity_drives_order_default(self): + features = [ + _feature(1, maturity="alpha", priority=1), + _feature(2, priority=2), + _feature(3, maturity="beta", priority=3), + _feature(4, maturity="limited_beta", priority=4), + ] + ready_ids = [f["id"] for f in get_ready_features(features)] + assert ready_ids == [3, 4, 1, 2] + + def test_maturity_order_configurable(self): + features = [ + _feature(1, maturity="beta"), + _feature(2, maturity="alpha"), + _feature(3, maturity="limited_beta"), + ] + ready_ids = [f["id"] for f in get_ready_features(features, maturity_order=["alpha", "limited_beta", "beta"])] + assert ready_ids == [2, 3, 1] + + def test_untagged_features_keep_scheduling_order(self): + """Without tags, ordering is unchanged from the dependency-aware score.""" + features = [_feature(1, priority=5), _feature(2, priority=1), _feature(3, priority=3)] + scores = compute_scheduling_scores(features) + legacy = sorted(features, key=lambda f: (-scores[f["id"]], f["priority"], f["id"])) + new = sorted(features, key=ready_sort_key(scores)) + assert [f["id"] for f in new] == [f["id"] for f in legacy] + + +# ============================================================================= +# Database +# ============================================================================= + + +class TestDatabase: + def test_migration_adds_roadmap_columns(self): + from api.database import create_database, dispose_engine + from autoforge_paths import get_features_db_path + + with tempfile.TemporaryDirectory() as tmp: + project_dir = Path(tmp) + db_path = get_features_db_path(project_dir) + db_path.parent.mkdir(parents=True, exist_ok=True) + # Legacy schema without the new columns + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE features (id INTEGER PRIMARY KEY, priority INTEGER NOT NULL, " + "category VARCHAR(100) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT NOT NULL, " + "steps JSON NOT NULL, passes BOOLEAN NOT NULL DEFAULT 0)" + ) + conn.execute( + "INSERT INTO features (priority, category, name, description, steps) " + "VALUES (1, 'core', 'Legacy', 'Legacy feature', '[]')" + ) + conn.commit() + conn.close() + + try: + create_database(project_dir) + conn = sqlite3.connect(db_path) + columns = {row[1] for row in conn.execute("PRAGMA table_info(features)")} + assert {"backlog", "maturity", "spec_notes"} <= columns + assert conn.execute("SELECT backlog, maturity FROM features").fetchone() == (0, None) + conn.close() + finally: + dispose_engine(project_dir) + + +# ============================================================================= +# REST API +# ============================================================================= + + +@pytest.fixture +def project_dir(): + from api.database import dispose_engine + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) + with patch("server.routers.features._get_project_path", return_value=path): + yield path + dispose_engine(path) + + +def _run(coro): + return asyncio.run(coro) + + +class TestRestApi: + def _create(self, **kwargs): + from server.routers.features import create_feature + from server.schemas import FeatureCreate + + data = { + "category": "core", + "name": "Feature", + "description": "A sufficiently detailed description of the feature", + "steps": ["Verify it works"], + **kwargs, + } + return _run(create_feature("demo", FeatureCreate(**data))) + + def test_create_in_backlog_with_maturity(self, project_dir): + from server.routers.features import list_features + + created = self._create(backlog=True, maturity="Limited Beta", spec_notes="Behind flag") + assert created.backlog is True + assert created.maturity == "limited_beta" + assert created.ready is True + + listing = _run(list_features("demo")) + assert [f.id for f in listing.backlog] == [created.id] + assert listing.pending == [] + + def test_promote_enforces_ready_checks(self, project_dir): + from server.routers.features import list_features, promote_feature + from server.schemas import PromoteRequest + + created = self._create(backlog=True) # no maturity tag -> not ready + assert created.ready is False + + with pytest.raises(HTTPException) as exc: + _run(promote_feature("demo", created.id, PromoteRequest())) + assert exc.value.status_code == 409 + assert [c["id"] for c in exc.value.detail["failed_checks"]] == ["maturity"] + + # Explicit human override + promoted = _run(promote_feature("demo", created.id, PromoteRequest(force=True))) + assert promoted.backlog is False + assert [f.id for f in _run(list_features("demo")).pending] == [created.id] + + def test_spec_review_then_promote(self, project_dir): + from server.routers.features import promote_feature, review_feature_spec + from server.schemas import PromoteRequest, SpecReviewRequest + + created = self._create(backlog=True) + reviewed = _run(review_feature_spec( + "demo", created.id, SpecReviewRequest(maturity="beta", notes="Don't break login") + )) + assert reviewed.maturity == "beta" + assert reviewed.spec_notes == "Don't break login" + assert reviewed.ready is True + + promoted = _run(promote_feature("demo", created.id, PromoteRequest())) + assert promoted.backlog is False + + def test_move_to_backlog_rules(self, project_dir): + from api.database import Feature, create_database + from server.routers.features import move_feature_to_backlog + + created = self._create() + moved = _run(move_feature_to_backlog("demo", created.id)) + assert moved.backlog is True + + # In-progress features cannot be parked + other = self._create() + _, SessionLocal = create_database(project_dir) + with SessionLocal() as session: + session.query(Feature).filter(Feature.id == other.id).update({"in_progress": True}) + session.commit() + with pytest.raises(HTTPException) as exc: + _run(move_feature_to_backlog("demo", other.id)) + assert exc.value.status_code == 409 + + def test_update_can_clear_maturity(self, project_dir): + from server.routers.features import update_feature + from server.schemas import FeatureUpdate + + created = self._create(maturity="alpha") + unchanged = _run(update_feature("demo", created.id, FeatureUpdate(name="Renamed"))) + assert unchanged.maturity == "alpha" + cleared = _run(update_feature("demo", created.id, FeatureUpdate(maturity=None))) + assert cleared.maturity is None + + def test_graph_reports_backlog_status(self, project_dir): + from server.routers.features import get_dependency_graph + + created = self._create(backlog=True, maturity="alpha") + graph = _run(get_dependency_graph("demo")) + node = next(n for n in graph.nodes if n.id == created.id) + assert node.status == "backlog" + assert node.maturity == "alpha" + + def test_mcp_cannot_claim_backlog_feature(self, project_dir): + import mcp_server.feature_mcp as feature_mcp + from api.database import create_database + + created = self._create(backlog=True, maturity="beta") + _, SessionLocal = create_database(project_dir) + with patch.object(feature_mcp, "_session_maker", SessionLocal): + claim = json.loads(feature_mcp.feature_claim_and_get(created.id)) + mark = json.loads(feature_mcp.feature_mark_in_progress(created.id)) + ready = json.loads(feature_mcp.feature_get_ready(10)) + assert "Backlog" in claim["error"] + assert "Backlog" in mark["error"] + assert ready["total_ready"] == 0 + + +class TestSettingsSchema: + def test_maturity_order_normalized_and_validated(self): + from pydantic import ValidationError + + from server.schemas import SettingsUpdate + + update = SettingsUpdate(maturity_order=["Limited Beta", "alpha", "BETA"]) + assert update.maturity_order == ["limited_beta", "alpha", "beta"] + with pytest.raises(ValidationError): + SettingsUpdate(maturity_order=["alpha", "alpha", "beta"]) + with pytest.raises(ValidationError): + SettingsUpdate(maturity_order=["alpha", "gamma", "beta"]) diff --git a/ui/src/components/AddFeatureForm.tsx b/ui/src/components/AddFeatureForm.tsx index 529b80110..13bcba3b4 100644 --- a/ui/src/components/AddFeatureForm.tsx +++ b/ui/src/components/AddFeatureForm.tsx @@ -1,6 +1,8 @@ import { useState, useId } from 'react' import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' import { useCreateFeature } from '../hooks/useProjects' +import type { Maturity } from '../lib/types' +import { MATURITY_DESCRIPTIONS, MATURITY_LABELS, MATURITY_LEVELS } from '../lib/maturity' import { Dialog, DialogContent, @@ -13,6 +15,7 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Checkbox } from '@/components/ui/checkbox' interface Step { id: string @@ -33,6 +36,8 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { const [steps, setSteps] = useState([{ id: `${formId}-step-0`, value: '' }]) const [error, setError] = useState(null) const [stepCounter, setStepCounter] = useState(1) + const [maturity, setMaturity] = useState(null) + const [addToBacklog, setAddToBacklog] = useState(false) const createFeature = useCreateFeature(projectName) @@ -67,6 +72,8 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { description: description.trim(), steps: filteredSteps, priority: priority ? parseInt(priority, 10) : undefined, + maturity, + backlog: addToBacklog, }) onClose() } catch (err) { @@ -194,6 +201,42 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { + {/* Maturity tag (spec review) */} +
+ +
+ {MATURITY_LEVELS.map(level => ( + + ))} +
+
+ + {/* Backlog */} +
+ setAddToBacklog(checked === true)} + /> +
+ +

+ Backlog items are never picked up by agents until promoted to Pending. +

+
+
+ {/* Actions */} + ) + })} + + Build queue:{' '} + {FILTER_OPTIONS.filter(o => o.value !== 'all') + .map(o => `${o.label} ${pendingByMaturity(o.value)}`) + .join(' · ')} + + + )} + +
- )} - + + + {showNeedsInput && ( + + )} + +
) } diff --git a/ui/src/components/KanbanColumn.tsx b/ui/src/components/KanbanColumn.tsx index 504fa921a..106027575 100644 --- a/ui/src/components/KanbanColumn.tsx +++ b/ui/src/components/KanbanColumn.tsx @@ -11,7 +11,8 @@ interface KanbanColumnProps { features: Feature[] allFeatures?: Feature[] activeAgents?: ActiveAgent[] - color: 'pending' | 'progress' | 'done' | 'human_input' + color: 'backlog' | 'pending' | 'progress' | 'done' | 'human_input' + emptyMessage?: string onFeatureClick: (feature: Feature) => void onAddFeature?: () => void onExpandProject?: () => void @@ -21,6 +22,7 @@ interface KanbanColumnProps { } const colorMap = { + backlog: 'border-t-4 border-t-muted border-dashed', pending: 'border-t-4 border-t-muted', progress: 'border-t-4 border-t-primary', done: 'border-t-4 border-t-primary', @@ -40,6 +42,7 @@ export function KanbanColumn({ showExpandButton, onCreateSpec, showCreateSpec, + emptyMessage = 'No features', }: KanbanColumnProps) { // Create a map of feature ID to active agent for quick lookup // Maps ALL batch feature IDs to the same agent @@ -99,7 +102,7 @@ export function KanbanColumn({ ) : ( - 'No features' + emptyMessage )} ) : ( diff --git a/ui/src/components/MaturityBadge.tsx b/ui/src/components/MaturityBadge.tsx new file mode 100644 index 000000000..bb301867a --- /dev/null +++ b/ui/src/components/MaturityBadge.tsx @@ -0,0 +1,30 @@ +import type { Maturity } from '../lib/types' +import { MATURITY_BADGE_CLASSES, MATURITY_DESCRIPTIONS, MATURITY_LABELS } from '../lib/maturity' +import { Badge } from '@/components/ui/badge' + +interface MaturityBadgeProps { + maturity?: Maturity | null + // Show an "Unreviewed" outline badge when no tag is set + showUnreviewed?: boolean +} + +export function MaturityBadge({ maturity, showUnreviewed = false }: MaturityBadgeProps) { + if (!maturity) { + if (!showUnreviewed) return null + return ( + + Unreviewed + + ) + } + + return ( + + {MATURITY_LABELS[maturity]} + + ) +} diff --git a/ui/src/components/MaturityOrderSetting.tsx b/ui/src/components/MaturityOrderSetting.tsx new file mode 100644 index 000000000..b71bb6cf5 --- /dev/null +++ b/ui/src/components/MaturityOrderSetting.tsx @@ -0,0 +1,62 @@ +import { ArrowUp, ArrowDown } from 'lucide-react' +import type { Maturity } from '../lib/types' +import { DEFAULT_MATURITY_ORDER, MATURITY_LABELS } from '../lib/maturity' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' + +interface MaturityOrderSettingProps { + order?: Maturity[] + disabled?: boolean + onChange: (order: Maturity[]) => void +} + +/** Reorderable list of maturity tags (first = picked up first by agents). */ +export function MaturityOrderSetting({ order: currentOrder, disabled = false, onChange }: MaturityOrderSettingProps) { + const order = currentOrder ?? DEFAULT_MATURITY_ORDER + + const move = (index: number, direction: -1 | 1) => { + const target = index + direction + if (disabled || target < 0 || target >= order.length) return + const next = [...order] + ;[next[index], next[target]] = [next[target], next[index]] + onChange(next) + } + + return ( +
+ +

+ Order in which agents pick up build-ready (Pending) features by maturity tag. + Unreviewed features go last. Applies the next time the agent starts. +

+
    + {order.map((level, index) => ( +
  1. + {index + 1} + {MATURITY_LABELS[level]} + + +
  2. + ))} +
+
+ ) +} diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 5605f4e63..22f3470c2 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react' import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects' import { useTheme, THEMES } from '../hooks/useTheme' -import type { EffortLevel, ProviderInfo } from '../lib/types' +import type { EffortLevel, Maturity, ProviderInfo } from '../lib/types' +import { MaturityOrderSetting } from './MaturityOrderSetting' import { Dialog, DialogContent, @@ -70,6 +71,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } } + const handleMaturityOrderChange = (order: Maturity[]) => { + if (!updateSettings.isPending) { + updateSettings.mutate({ maturity_order: order }) + } + } + const handleEffortChange = (level: EffortLevel) => { if (!updateSettings.isPending) { updateSettings.mutate({ effort: level }) @@ -490,6 +497,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { /> + + {/* Update Error */} {updateSettings.isError && ( diff --git a/ui/src/components/SpecReviewPanel.tsx b/ui/src/components/SpecReviewPanel.tsx new file mode 100644 index 000000000..bde3e75ca --- /dev/null +++ b/ui/src/components/SpecReviewPanel.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react' +import { Archive, ArrowUpCircle, CheckCircle2, ClipboardCheck, Loader2, XCircle } from 'lucide-react' +import { useMoveFeatureToBacklog, usePromoteFeature, useReviewFeatureSpec } from '../hooks/useProjects' +import type { Feature, Maturity } from '../lib/types' +import { MATURITY_DESCRIPTIONS, MATURITY_LABELS, MATURITY_LEVELS } from '../lib/maturity' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' + +interface SpecReviewPanelProps { + feature: Feature + projectName: string + onError: (message: string | null) => void +} + +/** + * Spec Review step + Backlog/Pending controls for a single feature. + * + * - Assigns a maturity tag (alpha / limited beta / beta) with optional notes. + * - Shows the ready checks that gate promotion from Backlog to Pending. + * - Promotes (with an explicit human override when checks fail) or parks + * the feature in the Backlog. + */ +export function SpecReviewPanel({ feature, projectName, onError }: SpecReviewPanelProps) { + const [maturity, setMaturity] = useState(feature.maturity ?? null) + const [notes, setNotes] = useState(feature.spec_notes ?? '') + const [confirmOverride, setConfirmOverride] = useState(false) + + const reviewSpec = useReviewFeatureSpec(projectName) + const promote = usePromoteFeature(projectName) + const moveToBacklog = useMoveFeatureToBacklog(projectName) + + const isDirty = maturity !== (feature.maturity ?? null) || notes !== (feature.spec_notes ?? '') + const readiness = feature.readiness ?? [] + const isBusy = reviewSpec.isPending || promote.isPending || moveToBacklog.isPending + + const run = async (action: () => Promise, fallback: string) => { + onError(null) + try { + await action() + return true + } catch (err) { + onError(err instanceof Error ? err.message : fallback) + return false + } + } + + const handleSaveReview = () => { + if (!maturity) return + run( + () => reviewSpec.mutateAsync({ featureId: feature.id, maturity, notes: notes.trim() || null }), + 'Failed to save spec review', + ) + } + + const handlePromote = async (force: boolean) => { + const ok = await run( + () => promote.mutateAsync({ featureId: feature.id, force }), + 'Failed to promote feature', + ) + if (ok) setConfirmOverride(false) + } + + return ( +
+

+ + Spec Review +

+ + {/* Maturity tag */} +
+ +
+ {MATURITY_LEVELS.map(level => ( + + ))} +
+

+ {maturity ? MATURITY_DESCRIPTIONS[maturity] : 'Required before the feature can move from Backlog to Pending.'} +

+
+ + {/* Notes */} +
+ +