Skip to content
Merged
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
14 changes: 7 additions & 7 deletions backend/app/api/v1/generation_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
EstimationSummary,
WorkspaceEstimation,
ComparativeAnalysis,
ComponentComparison,
PhaseComparison,
RiskAssessment,
SkippedWorkspaceP10Y,
)
Expand Down Expand Up @@ -992,17 +992,17 @@ def _reconstruct_generation_session_response(
continue

comp_analysis_data = stored_result.get("comparative_analysis") or {}
component_comparison = {}
if "component_comparison" in comp_analysis_data:
for comp_name, comp_comp_data in comp_analysis_data["component_comparison"].items():
phase_comparison = {}
if "phase_comparison" in comp_analysis_data:
for phase_key, phase_comp_data in comp_analysis_data["phase_comparison"].items():
try:
component_comparison[comp_name] = ComponentComparison(**comp_comp_data)
phase_comparison[phase_key] = PhaseComparison(**phase_comp_data)
except Exception:
continue

comparative_analysis = ComparativeAnalysis(
component_comparison=component_comparison,
high_variance_components=comp_analysis_data.get("high_variance_components", []),
phase_comparison=phase_comparison,
high_variance_phases=comp_analysis_data.get("high_variance_phases", []),
insights=comp_analysis_data.get("insights", []),
)

Expand Down
112 changes: 60 additions & 52 deletions backend/app/core/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ def build_coding_complete_pre_deploy_response(
workspace_path="",
total_hours=0.0,
total_effective_output=0.0,
component_breakdown={},
phase_breakdown={},
estimation_metrics=zero_metrics,
commits_count=0,
p10y_scored_commits=0,
Expand All @@ -414,10 +414,10 @@ def build_coding_complete_pre_deploy_response(
),
)
comparative_analysis = ComparativeAnalysis(
component_comparison={},
high_variance_components=[],
phase_comparison={},
high_variance_phases=[],
insights=[
"P10Y hour estimates and component breakdown are not available yet; "
"P10Y hour estimates and phase breakdown are not available yet; "
"they will be included in the final notification after deployment and the P10Y phase.",
],
)
Expand Down Expand Up @@ -821,20 +821,20 @@ def _build_generation_session_slack_blocks(
if cost_ln:
lines.append(cost_ln)

# Component breakdown: show top components by hours
component_breakdown = getattr(ws_est, "component_breakdown", None)
if component_breakdown:
sorted_comps = sorted(
component_breakdown.items(),
# Phase breakdown: show phases by hours (highest first)
phase_breakdown = getattr(ws_est, "phase_breakdown", None)
if phase_breakdown:
sorted_phases = sorted(
phase_breakdown.items(),
key=lambda kv: getattr(kv[1], "hours", 0.0),
reverse=True,
)
comp_parts = [
f"{name}: {getattr(comp, 'hours', 0.0):.1f}h"
for name, comp in sorted_comps
phase_parts = [
f"{getattr(ph, 'phase_name', key)}: {getattr(ph, 'hours', 0.0):.1f}h"
for key, ph in sorted_phases
]
if comp_parts:
lines.append(" " + " | ".join(comp_parts))
if phase_parts:
lines.append(" " + " | ".join(phase_parts))

blocks.append({
"type": "section",
Expand Down Expand Up @@ -1195,40 +1195,44 @@ def render_generation_session_report_html(
except Exception as e:
logger.warning(f"Failed to retrieve workspace {workspace_id}: {e}")

# Extract component breakdown
component_breakdown = {}
# Extract per-phase breakdown (phase key "NN"/"unphased" -> number, name, per-workspace hours)
phase_breakdown = {}
if workspace_estimations:
# Collect all unique components across all workspaces
all_components = set()
# Collect all unique phase keys across all workspaces
all_keys = set()
for ws_est in workspace_estimations:
try:
if hasattr(ws_est, 'component_breakdown') and ws_est.component_breakdown:
all_components.update(ws_est.component_breakdown.keys())
if hasattr(ws_est, 'phase_breakdown') and ws_est.phase_breakdown:
all_keys.update(ws_est.phase_breakdown.keys())
except (AttributeError, TypeError):
continue # Skip this workspace if component_breakdown is missing or invalid

# Build component breakdown with hours per workspace
for component_name in sorted(all_components):
component_data = {
"name": component_name,
continue # Skip this workspace if phase_breakdown is missing or invalid

# Build phase breakdown with hours per workspace (plan order; unphased sorts last)
for phase_key in sorted(all_keys):
phase_data = {
"key": phase_key,
"number": None,
"name": phase_key,
"workspaces": {}
}
for ws_est in workspace_estimations:
try:
if (hasattr(ws_est, 'component_breakdown') and
component_name in ws_est.component_breakdown):
comp = ws_est.component_breakdown[component_name]
if (hasattr(ws_est, 'phase_breakdown') and
phase_key in ws_est.phase_breakdown):
ph = ws_est.phase_breakdown[phase_key]
phase_data["number"] = getattr(ph, 'phase_number', None)
phase_data["name"] = getattr(ph, 'phase_name', phase_key)
workspace_name = getattr(ws_est, 'workspace_name', 'unknown')
component_data["workspaces"][workspace_name] = {
"hours": getattr(comp, 'hours', 0.0),
"new_work": getattr(comp, 'new_work', 0.0),
"refactor": getattr(comp, 'refactor', 0.0),
"rework": getattr(comp, 'rework', 0.0),
"quality_score": getattr(comp, 'quality_score', 0.0)
phase_data["workspaces"][workspace_name] = {
"hours": getattr(ph, 'hours', 0.0),
"new_work": getattr(ph, 'new_work', 0.0),
"refactor": getattr(ph, 'refactor', 0.0),
"rework": getattr(ph, 'rework', 0.0),
"quality_score": getattr(ph, 'quality_score', 0.0)
}
except (AttributeError, TypeError, KeyError):
continue # Skip this workspace/component if data is missing
component_breakdown[component_name] = component_data
continue # Skip this workspace/phase if data is missing
phase_breakdown[phase_key] = phase_data

# Build HTML content
html_parts = []
Expand Down Expand Up @@ -1355,13 +1359,14 @@ def render_generation_session_report_html(
html_parts.append('</div>')
html_parts.append('</div>')

# Component breakdown section
if component_breakdown:
# Phase breakdown section
if phase_breakdown:
html_parts.append('<div class="section">')
html_parts.append('<h2>Component Complexity Metrics Breakdown</h2>')
html_parts.append('<h2>Phase Breakdown</h2>')
html_parts.append('<table>')
html_parts.append('<thead><tr>')
html_parts.append('<th>Component</th>')
html_parts.append('<th>Phase #</th>')
html_parts.append('<th>Description</th>')
# Add workspace columns
if workspace_estimations:
for ws_est in workspace_estimations:
Expand All @@ -1373,16 +1378,18 @@ def render_generation_session_report_html(
html_parts.append('</tr></thead>')
html_parts.append('<tbody>')

for component_name, component_data in component_breakdown.items():
for _phase_key, phase_data in phase_breakdown.items():
num = f'{phase_data["number"]:02d}' if phase_data["number"] is not None else '—'
html_parts.append('<tr>')
html_parts.append(f'<td><strong>{component_name}</strong></td>')
html_parts.append(f'<td>{num}</td>')
html_parts.append(f'<td><strong>{phase_data["name"]}</strong></td>')
# Add hours for each workspace
if workspace_estimations:
for ws_est in workspace_estimations:
try:
ws_name = getattr(ws_est, 'workspace_name', 'unknown')
if ws_name in component_data["workspaces"]:
hours = component_data["workspaces"][ws_name]["hours"]
if ws_name in phase_data["workspaces"]:
hours = phase_data["workspaces"][ws_name]["hours"]
html_parts.append(f'<td>{hours:.1f}</td>')
else:
html_parts.append('<td>-</td>')
Expand Down Expand Up @@ -1457,11 +1464,11 @@ def render_generation_session_report_html(
pass
plain_parts.append("")

if component_breakdown:
plain_parts.append("COMPONENT BREAKDOWN:")
if phase_breakdown:
plain_parts.append("PHASE BREAKDOWN:")
plain_parts.append("-" * 60)
# Build header
header = "Component"
header = "Phase"
if workspace_estimations:
for ws_est in workspace_estimations:
try:
Expand All @@ -1472,14 +1479,15 @@ def render_generation_session_report_html(
plain_parts.append(header)
plain_parts.append("-" * len(header))

for component_name, component_data in component_breakdown.items():
row = component_name
for _phase_key, phase_data in phase_breakdown.items():
num = f'{phase_data["number"]:02d}' if phase_data["number"] is not None else '—'
row = f'{num} {phase_data["name"]}'
if workspace_estimations:
for ws_est in workspace_estimations:
try:
ws_name = getattr(ws_est, 'workspace_name', 'unknown')
if ws_name in component_data["workspaces"]:
hours = component_data["workspaces"][ws_name]["hours"]
if ws_name in phase_data["workspaces"]:
hours = phase_data["workspaces"][ws_name]["hours"]
row += f" | {hours:.1f}"
else:
row += " | -"
Expand Down
27 changes: 13 additions & 14 deletions backend/app/prompts/agents_claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,9 +844,10 @@ def phase_workflow_instructions(outputs_dir: str, phase_number: int):
- Write the code following all engineering standards above
- Write unit tests for the functionality
- Verify code integrity (run linters/tests if the environment allows)
- **COMMIT** the changes with proper component attribution (see {COMMIT_STANDARDS_FILE_REL})
* **Strict subject line**: `<component>_<action and subject>` (underscore after component name, rest is free text)
* Example: `backend_implement JWT token generation and validation`
- **COMMIT** the changes with a clear, descriptive message (see {COMMIT_STANDARDS_FILE_REL})
* **Subject line**: a plain `<action> <subject>` — do NOT add any component or phase prefix
* The implementation-plan phase prefix (`pNN_`) is added automatically by a git hook
* Example: `implement JWT token generation and validation`
* Do not mention contributors or coauthors
* **IMPORTANT — only your phase commits**:
- After each commit, optionally run `git log -1 --oneline` to confirm the message matches the schema
Expand Down Expand Up @@ -948,13 +949,11 @@ def generate_production_agent_template(
- **CRITICAL:** Whenever a logical component (e.g., a specific service, a feature, a module, or a set of related tests) is finished and stable, you must run `git add` and `git commit` with a descriptive message.
- This commit history represents the "human" progress timeline.

4. **Commit Strategy & Component Tracking (LOCKED GRANULARITY):**
4. **Commit Strategy (LOCKED GRANULARITY):**
- **REQUIRED**: Follow commit standards defined in {COMMIT_STANDARDS_FILE_REL}
- **MANDATORY**: Every commit subject encodes one primary component using this **strict first-line format**:
`<component>_<action and subject>`
- The **first** underscore separates the component token from the rest (e.g. `backend_implement user service`).
- Use a component token from {COMMIT_STANDARDS_FILE_REL} (`backend`, `frontend`, `mobile`, `testing`, `infrastructure`, …).
- See {COMMIT_STANDARDS_FILE_REL} for component list, `SKIP_` rules for non-generation commits, and examples
- Write a plain, descriptive `<action> <subject>` first line — do NOT add any component or phase prefix yourself.
- The implementation-plan phase prefix (`pNN_`) is added automatically by a git hook, so P10Y attribution never depends on your commit hygiene.
- See {COMMIT_STANDARDS_FILE_REL} for the `SKIP_` rule for non-generation commits, and examples
- **CRITICAL: COMMIT GRANULARITY IS STANDARDIZED**
- **Target**: 40-50 commits for typical applications (scales with complexity)
- Commit when completing a logical unit within a single component or a phase
Expand Down Expand Up @@ -1431,7 +1430,7 @@ def qa_agent_prompt(outputs_dir: str):
def estimation_report_agent_template(
summary: EstimationSummary,
workspace_summaries: List[str],
component_comparison_text: str,
phase_comparison_text: str,
comparative_analysis: ComparativeAnalysis,
full_outputs_dir: str,
model: str,
Expand All @@ -1452,11 +1451,11 @@ def estimation_report_agent_template(
### Workspace Results:
{chr(10).join(workspace_summaries)}

### Component Comparison:
{chr(10).join(component_comparison_text)}
### Phase Comparison:
{chr(10).join(phase_comparison_text)}

### High Variance Components:
{', '.join(comparative_analysis.high_variance_components) if comparative_analysis.high_variance_components else 'None'}
### High Variance Phases:
{', '.join(comparative_analysis.high_variance_phases) if comparative_analysis.high_variance_phases else 'None'}

### Key Insights:
{chr(10).join(f"- {insight}" for insight in comparative_analysis.insights)}
Expand Down
25 changes: 16 additions & 9 deletions backend/app/schemas/estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ class EstimateGenerateResponse(BaseModel):


# Multi-workspace P10Y estimation models
class ComponentEstimation(BaseModel):
"""Estimation metrics for a single component."""
component_name: str
class PhaseEstimation(BaseModel):
"""Estimation metrics for a single implementation-plan phase."""
phase_number: Optional[int] # None for the "unphased" bucket
phase_name: str
hours: float
new_work: float
refactor: float
Expand All @@ -64,10 +65,15 @@ class WorkspaceEstimation(BaseModel):
workspace_path: str
total_hours: float
total_effective_output: float
component_breakdown: Dict[str, ComponentEstimation]
phase_breakdown: Dict[str, PhaseEstimation]
estimation_metrics: EstimationMetrics
commits_count: int
p10y_scored_commits: Optional[int] = None
p10y_returned_commits: Optional[int] = Field(
default=None,
description="Eligible commits P10Y returned a row for, before the valid-technology "
"filter. Lets coverage be split into service-gap vs non-code (docs/config) causes.",
)
model_usage: Optional[ModelTokenUsage] = None
total_usd_cost: Optional[float] = Field(
default=None,
Expand All @@ -88,9 +94,10 @@ def _serialize_model_usage(self, v: Optional[ModelTokenUsage]) -> Optional[Dict[
return None if v is None else v.to_dict()


class ComponentComparison(BaseModel):
"""Comparison of a component across workspaces."""
component_name: str
class PhaseComparison(BaseModel):
"""Comparison of one implementation-plan phase across workspaces."""
phase_number: Optional[int] # None for the "unphased" bucket
phase_name: str
hours_by_workspace: Dict[str, float] # workspace_name -> hours
average: float
std_deviation: float
Expand All @@ -99,8 +106,8 @@ class ComponentComparison(BaseModel):

class ComparativeAnalysis(BaseModel):
"""Comparative analysis across all workspaces."""
component_comparison: Dict[str, ComponentComparison]
high_variance_components: List[str]
phase_comparison: Dict[str, PhaseComparison]
high_variance_phases: List[str]
insights: List[str]


Expand Down
23 changes: 23 additions & 0 deletions backend/app/services/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
classify_error,
get_fallback_model,
)
from app.services.git_utils import run_git
from app.utils.workspace_git_hooks import ensure_workspace_git_hooks
from app.schemas.deploy_context import DeployGithubContext
from app.schemas.planning import PlanningResult
from app.schemas.specification import GenerateAppRequest, SpecReadiness
Expand Down Expand Up @@ -1404,6 +1406,19 @@ async def execute_all_phases(
f"(out of {planning_data.phase_count} total phases)"
)

# Deterministic phase attribution: install the prepare-commit-msg hook that stamps
# each commit subject with pNN_. Done here (not in a setup path) so it is guaranteed
# present before any commit regardless of how .git was created (init/clone/reset).
# The hook reads `specflow.phase`, set per codegen phase in the loop below; for the
# deploy loop we clear it so deploy commits stay unphased.
hook_root = Path(workspace.get_isolated_root())
ensure_workspace_git_hooks(hook_root)
if is_deployment:
try:
await run_git(hook_root, ["config", "specflow.phase", ""])
except Exception as exc:
logger.warning(f"[{workspace_name}] could not clear phase marker (non-fatal): {exc}")

step = WorkflowStepName.DEPLOY_AND_E2E if is_deployment else WorkflowStepName.GENERATION
selector = McpSelector(manager.settings, enabled_mcps, logger)

Expand Down Expand Up @@ -1446,6 +1461,14 @@ async def execute_all_phases(
TelemetryContext.set_phase_name(phase_info.name or "")

workspace_root = workspace.get_isolated_root()
if not is_deployment:
# Stamp commits made during this phase with pNN_ (consumed by the hook above).
try:
await run_git(Path(workspace_root), ["config", "specflow.phase", str(phase_num)])
except Exception as exc:
logger.warning(
f"[{workspace_name}] could not set phase marker {phase_num} (non-fatal): {exc}"
)
if is_deployment:
phase_prompt = generate_deploy_phase_agent_template(
model=workspace.model,
Expand Down
Loading