Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/templates/coding_prompt.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions api/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions api/dependency_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
159 changes: 159 additions & 0 deletions api/roadmap.py
Original file line number Diff line number Diff line change
@@ -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"]]
24 changes: 19 additions & 5 deletions mcp_server/feature_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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],
Expand Down
Loading