use std::sync::mpsc::Sender; use anyhow::Result; use serde_json::{json, to_string_pretty}; use crate::app::AppEvent; use crate::model::{ self, ControllerState, LegacyOutputProjection, PlannerResponse, PlanningConflictStrategy, PlanningContract, PlanningPersona, PlanningPersonaPass, Plan, SessionEntry, SessionSource, SessionStream, TaskConfig, }; use crate::prompt; use crate::process; use crate::repo; const MAX_TRANSCRIPT_ITEMS: usize = 6; const MAX_TRANSCRIPT_CHARS: usize = 240; const PLANNING_PERSONA_TEMPLATE_VERSION: u32 = 1; const PLANNING_ROLLOUT_ALERT_THRESHOLD: u32 = 3; const EXPECTED_PERSONA_CHAIN: [&str; 3] = [ "product-owner", "senior-engineer", "senior-maintainer", ]; const EXPECTED_STAGE_TAGS: [&str; 3] = ["stage-1", "stage-2", "stage-3"]; const OWNERSHIP_BOUNDARY_MARKERS: [&str; 6] = [ "ownership", "owner", "module boundary", "interface boundary", "contract", "separation", ]; const ITERATION_CONTEXT_MARKERS: [&str; 6] = [ "iteration", "cleanup", "roll-forward", "next iteration", "replan", "long-term", ]; const RISKY_SHORTCUT_MARKERS: [&str; 9] = [ "quick fix", "workaround", "hack", "hardcode", "band-aid", "temporary", "bypass", "shortcut", "ad hoc", ]; const MAINTAINABILITY_MARKERS: [&str; 17] = [ "maintain", "maintainability", "maintainable", "modularity", "modular", "ownership", "module boundary", "interface boundary", "coupling", "rollback", "refactor", "cleanup", "migration", "debt", "evolv", "separation", "long-term", ]; const CORRECTNESS_ONLY_MARKERS: [&str; 10] = [ "correctness", "pass", "passes", "passing", "works", "working", "regression", "stable", "all tests", "no break", ]; #[derive(Debug, Clone)] struct StageWorkingSet { goal_md: String, standards_md: String, plan: Plan, constraints: Vec, risks: Vec, plan_projection: LegacyOutputProjection, } pub fn planning_schema() -> serde_json::Value { model::planner_contract_schema() } pub fn build_planning_prompt( config: &TaskConfig, goal_md: &str, standards_md: &str, state: &ControllerState, latest_user_input: &str, ) -> String { build_persona_planning_prompt( config, state, goal_md, standards_md, latest_user_input, &Plan::default(), &PlanningContract::default(), &PlanningPersona::ProductOwner, true, ) } pub fn run_planning_pipeline( repo_root: &std::path::Path, config: &TaskConfig, state: &mut ControllerState, initial_goal_md: &str, initial_standards_md: &str, initial_plan: &Plan, latest_user_input: &str, event_tx: &Sender, ) -> Result { let contract = PlanningContract::default(); let mut working_set = StageWorkingSet { goal_md: initial_goal_md.to_string(), standards_md: initial_standards_md.to_string(), plan: initial_plan.clone(), constraints: Vec::new(), risks: Vec::new(), plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; if !contract_has_expected_persona_chain(&contract) { return Ok(planning_pipeline_question_response( &contract, &working_set, "chain-config", &[], "[pipeline] Stage chain configuration is invalid for planner telemetry." .to_string(), )); } let mut persona_passes = Vec::new(); for (stage_index, persona) in contract.ordered_personas.iter().enumerate() { let allow_question = stage_index == 0; let prompt = build_persona_planning_prompt( config, state, &working_set.goal_md, &working_set.standards_md, latest_user_input, &working_set.plan, &contract, persona, allow_question, ); let raw = process::run_codex_with_schema( repo_root, &prompt, &planning_schema(), state.run_model(), event_tx, crate::model::SessionSource::Planner, Some(config.controller_id()), )?; let response = parse_planning_response(&raw)?; let stage_pass = extract_persona_pass(&response, persona).unwrap_or_else(|| { fallback_persona_pass(persona) }); let stage_label = EXPECTED_STAGE_TAGS[stage_index]; emit_persona_stage_observation( event_tx, stage_label, persona, &stage_pass, &response, &contract, ); emit_stage_transition_observation( event_tx, stage_label, persona, &stage_pass, &response, &contract, ); if response.planning_contract_version != contract.contract_version { record_rollout_counter( event_tx, config, state, "rejected", stage_label, "unsupported-planning-contract-version", ); let mut question = format!( "[pipeline] Rejected stage payload: {stage_label} did not return contract version {}.", contract.contract_version ); if state.fast_mode && !question.starts_with("[pipeline]") { question = format!("[pipeline] {question}"); } let mut blocked_passes = persona_passes.clone(); blocked_passes.push(stage_pass.clone()); return Ok(planning_pipeline_question_response( &contract, &working_set, "unsupported-planning-contract-version", &blocked_passes, question, )); } if response.kind == "question" && !allow_question { record_rollout_counter( event_tx, config, state, "rejected", stage_label, "downstream-question", ); return Ok(planning_pipeline_question_response( &contract, &working_set, "downstream-question", &persona_passes, format!( "[pipeline] {stage_label} cannot request clarification before prior stages complete." ), )); } merge_stage_pass( &contract, &mut working_set, persona, &response, &stage_pass, )?; persona_passes.push(stage_pass); if response.kind == "question" && allow_question { let persona_passes = canonicalize_persona_passes(&contract, persona_passes); let mut question = response .question .unwrap_or_else(|| "The planning stage requires clarification.".to_string()); if state.fast_mode && !question.starts_with("[pipeline]") { question = format!("[pipeline] {question}"); } return Ok(PlannerResponse { kind: "question".to_string(), question: Some(question), goal_md: Some(working_set.goal_md), standards_md: Some(working_set.standards_md), plan: Some(working_set.plan), planning_contract_version: contract.contract_version, contract: Some(contract), persona_passes, single_pass_projection: Some(working_set.plan_projection), quality_gate: model::PlanningQualityGate::default(), }); } } let persona_passes = canonicalize_persona_passes(&contract, persona_passes); let mut quality_gate = evaluate_quality_gate(state, &working_set, &persona_passes); if quality_gate.decision_code != model::PlanningQualityDecisionCode::Accept { for code in quality_gate.rationale_codes.iter() { if matches!(quality_gate.decision_code, model::PlanningQualityDecisionCode::Blocked) { record_rollout_counter( event_tx, config, state, "rejected", EXPECTED_STAGE_TAGS[2], code, ); } else { record_rollout_counter( event_tx, config, state, "annotated", EXPECTED_STAGE_TAGS[2], code, ); } } } if quality_gate.decision_code == model::PlanningQualityDecisionCode::Blocked { if quality_gate.rationale.is_empty() { quality_gate.rationale.push( "Quality gate blocked execution because maintainability risks remain unresolved." .to_string(), ); } let mut question = format!( "Blocked by quality gate ({}): {}", quality_gate .rationale_codes .first() .cloned() .unwrap_or_else(|| "quality-maintenance-risk".to_string()), quality_gate.rationale.join(" ") ); if state.fast_mode && !question.starts_with("[pipeline]") { question = format!("[pipeline] {question}"); } return Ok(PlannerResponse { kind: "question".to_string(), question: Some(question), goal_md: Some(working_set.goal_md), standards_md: Some(working_set.standards_md), plan: Some(working_set.plan), planning_contract_version: contract.contract_version, contract: Some(contract), persona_passes, single_pass_projection: Some(working_set.plan_projection), quality_gate, }); } let quality_annotations = quality_gate_annotations(&quality_gate); if !quality_annotations.is_empty() { let mut goal_prefix = String::new(); for annotation in quality_annotations { goal_prefix.push_str(&format!("\n- {annotation}")); } working_set.standards_md = format!( "{}\n\n## Quality Gate Annotations\n{}\n", working_set.standards_md, goal_prefix ); } Ok(PlannerResponse { kind: "final".to_string(), question: None, goal_md: Some(working_set.goal_md), standards_md: Some(working_set.standards_md), plan: Some(working_set.plan), planning_contract_version: contract.contract_version, contract: Some(contract), persona_passes, single_pass_projection: Some(working_set.plan_projection), quality_gate, }) } fn planning_pipeline_question_response( contract: &PlanningContract, working_set: &StageWorkingSet, failure_type: &str, persona_passes: &[PlanningPersonaPass], mut question: String, ) -> PlannerResponse { if !question.starts_with("[pipeline") { question = format!("[pipeline:{failure_type}] {question}"); } PlannerResponse { kind: "question".to_string(), question: Some(question), goal_md: Some(working_set.goal_md.clone()), standards_md: Some(working_set.standards_md.clone()), plan: Some(working_set.plan.clone()), planning_contract_version: contract.contract_version, contract: Some(contract.clone()), persona_passes: canonicalize_persona_passes(contract, persona_passes.to_vec()), single_pass_projection: Some(working_set.plan_projection.clone()), quality_gate: model::PlanningQualityGate::default(), } } fn contract_has_expected_persona_chain(contract: &PlanningContract) -> bool { contract.ordered_personas.len() == EXPECTED_PERSONA_CHAIN.len() && contract .ordered_personas .iter() .map(persona_label) .eq(EXPECTED_PERSONA_CHAIN.iter().copied()) } fn persona_label(persona: &PlanningPersona) -> &'static str { match persona { PlanningPersona::ProductOwner => "product-owner", PlanningPersona::SeniorEngineer => "senior-engineer", PlanningPersona::SeniorMaintainer => "senior-maintainer", } } fn record_rollout_counter( event_tx: &Sender, config: &TaskConfig, state: &mut ControllerState, category: &str, stage_label: &str, reason: &str, ) { let count = if category == "annotated" { state.increment_planning_annotation_counter(stage_label, reason) } else { state.increment_planning_rejection_counter(stage_label, reason) }; if count == PLANNING_ROLLOUT_ALERT_THRESHOLD { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Warning, stream: SessionStream::Status, title: "Planner rollout alert".to_string(), tag: Some(config.controller_id()), body: format!( "Rollout threshold breached for {category} counter {stage_label}:{reason} at {count}." ), run_id: repo::next_run_id(), })); } } pub fn build_persona_planning_prompt( config: &TaskConfig, state: &ControllerState, goal_md: &str, standards_md: &str, latest_user_input: &str, plan: &Plan, contract: &PlanningContract, persona: &PlanningPersona, _allow_question: bool, ) -> String { let transcript = prompt::compact_turns( &state.planning_session.transcript, MAX_TRANSCRIPT_ITEMS, MAX_TRANSCRIPT_CHARS, ); let contract_json = to_string_pretty(contract).unwrap_or_else(|_| "{}".to_string()); let plan = to_string_pretty(plan).unwrap_or_else(|_| "{}".to_string()); let (persona_label, persona_focus, question_rule) = persona_instructions(persona); format!( concat!( "You are embedded Codex planning mode for a Rust autonomous controller.\n", "This is the {persona_label} stage of a deterministic three-stage chain.\n", "Stages must always run in order: product-owner -> senior-engineer -> senior-maintainer.\n", "Do not ask the user any questions unless explicitly allowed.\n", "Chain template version: planning-persona-chain-v{template_version}\n", "Merge composition:\n{merge_rules}\n\n", "Always return only one JSON object matching the contract.\n\n", "Rules:\n", "- Keep the output minimal and execution-safe.\n", "- Do not invent repository details.\n", "- Always include all response keys.\n", "- Use null for any field that does not apply in this response, except quality_gate which must always be a full object.\n", "- Output goal_md, standards_md, and plan should be complete enough for autonomous execution.\n", "- Return plan steps with one-sentence notes and stable field order.\n", "- Prefer 3-6 steps unless the goal truly needs more.\n", "- Keep each plan step.note to one short sentence.\n", "- Ask at most one follow-up question only when explicitly allowed.\n", "- Stage focus: {persona_focus}\n", "- Avoid correctness-only solutions that create high maintenance cost.\n", "- If maintenance cost is high, keep constraints and risks explicit.\n", "- If this is not the first stage, do not return kind=question.\n\n", "Contract:\n{contract}\n\n", "Persona stage:\n- name: {persona_label}\n\n", "Conflict instructions:\n", "- Resolve field conflicts by ordered contract rules; latest stage wins unless append-unique.\n\n", "Question policy: {question_rule}\n\n", "Branching:\n{branching}\n\n", "Run mode:\n{run_mode}\n\n", "Iteration context:\n{iteration_context}\n\n", "Current goal summary:\n{goal}\n\n", "Current standards summary:\n{standards}\n\n", "Current plan:\n{plan}\n\n", "Recent transcript:\n{transcript}\n\n", "Latest user input:\n{latest}\n\n", "When returning kind=final, include:\n", "- goal_md: rewritten goal markdown\n", "- standards_md: rewritten standards markdown\n", "- plan: structured machine-readable plan object with ordered steps, concise step notes, verification, cleanup requirements, and statuses.\n", "- persona_passes must include this persona intent, constraints, risks, acceptance_criteria, and evidence.\n" ), persona_label = persona_label, persona_focus = persona_focus, question_rule = question_rule, template_version = PLANNING_PERSONA_TEMPLATE_VERSION, merge_rules = build_contract_merge_rules(contract), contract = contract_json, plan = plan, branching = if state.allow_branching { format!( "branching is allowed if clearly helpful; preferred branch is {}", config.branch ) } else { "branching disabled unless the user explicitly asks for it".to_string() }, run_mode = if state.fast_mode { "fast mode enabled; prefer fewer, broader steps".to_string() } else { "normal mode".to_string() }, iteration_context = serde_json::to_string_pretty(&json!({ "iteration": state.iteration, "goal_revision": state.goal_revision, "replan_required": state.replan_required, "notes_count": state.notes.len(), })) .unwrap_or_else(|_| "{}".to_string()), goal = prompt::compact_markdown(goal_md, 10, 1400), standards = prompt::compact_markdown(standards_md, 10, 1200), transcript = transcript, latest = prompt::truncate_text(latest_user_input, 400), ) } pub fn parse_planning_response(raw: &str) -> anyhow::Result { let mut response: PlannerResponse = serde_json::from_str(raw)?; if response.planning_contract_version == model::LEGACY_GOAL_PLANNING_CONTRACT_VERSION { response.contract.get_or_insert_with(model::PlanningContract::default); if response.single_pass_projection.is_none() && matches!(response.kind.as_str(), "final") { response.single_pass_projection = Some(model::LegacyOutputProjection { goal_md_stage: model::PlanningPersona::SeniorMaintainer, standards_md_stage: model::PlanningPersona::SeniorMaintainer, plan_stage: model::PlanningPersona::SeniorMaintainer, }); } } Ok(response) } fn evaluate_quality_gate( state: &ControllerState, working_set: &StageWorkingSet, persona_passes: &[PlanningPersonaPass], ) -> model::PlanningQualityGate { let mut rationale_codes = Vec::new(); if !has_ownership_boundary_signal(working_set) { rationale_codes.push("missing-ownership-boundaries".to_string()); } if lacks_vague_acceptance_guardrails(persona_passes) { rationale_codes.push("vague-acceptance-criteria".to_string()); } if has_risky_shortcut_pattern(&working_set.plan) || has_risky_shortcut_pattern_in_passes(persona_passes) { rationale_codes.push("risky-shortcut-pattern".to_string()); } if has_correctness_only_optimization(persona_passes) { rationale_codes.push("correctness-only-optimization".to_string()); } if has_missing_iteration_review_context(state, personaevidence_to_text(persona_passes)) { rationale_codes.push("missing-iteration-review".to_string()); } let mut rationale = rationale_codes .iter() .map(|code| quality_rationale_text(code)) .collect::>(); let decision_code = match rationale_codes.as_slice() { r if r.contains(&"risky-shortcut-pattern".to_string()) => model::PlanningQualityDecisionCode::Blocked, [] => model::PlanningQualityDecisionCode::Accept, r if r.len() <= 2 && !r.contains(&"missing-ownership-boundaries".to_string()) => { model::PlanningQualityDecisionCode::Downgraded } r if !r.contains(&"vague-acceptance-criteria".to_string()) => { model::PlanningQualityDecisionCode::Downgraded } _ => model::PlanningQualityDecisionCode::Downgraded, }; if matches!(decision_code, model::PlanningQualityDecisionCode::Accept) { rationale.push("plan passes maintainability and iteration-aware quality gates".to_string()); } model::PlanningQualityGate { quality_gate_version: crate::model::PLANNING_QUALITY_GATE_VERSION, decision_code, rationale_codes, rationale, } } fn quality_gate_annotations(gate: &model::PlanningQualityGate) -> Vec { if matches!(gate.decision_code, model::PlanningQualityDecisionCode::Accept) { return vec!["Iteration-aware review note: confirm long-term ownership and cleanup path." .to_string()]; } let mut annotations = Vec::new(); for code in &gate.rationale_codes { match code.as_str() { "missing-ownership-boundaries" => { annotations.push( "Record ownership boundaries before implementation and align responsibilities per module." .to_string(), ); } "vague-acceptance-criteria" => { annotations.push( "Replace ambiguous success criteria with measurable architecture and maintenance checks." .to_string(), ); } "risky-shortcut-pattern" => { annotations.push( "Remove shortcut approaches and require explicit design-safe alternatives in plan." .to_string(), ); } "missing-iteration-review" => { annotations.push( "Add iteration-aware follow-up/review criteria for rollback, observability, and cleanup." .to_string(), ); } "correctness-only-optimization" => { annotations.push( "Balance correctness goals with architecture and maintenance acceptance criteria." .to_string(), ); } _ => {} } } annotations } fn quality_rationale_text(code: &str) -> String { match code { "missing-ownership-boundaries" => { "No explicit ownership boundary constraints were provided across persona passes." .to_string() } "vague-acceptance-criteria" => { "Acceptance criteria lacked explicit architecture or iterability requirements." .to_string() } "risky-shortcut-pattern" => { "Potential short-term shortcut language appears in constraints, risks, or plan." .to_string() } "missing-iteration-review" => { "Iteration-aware maintenance review context was not explicitly preserved." .to_string() } "correctness-only-optimization" => { "Acceptance criteria emphasizes short-term correctness without explicit maintenance goals." .to_string() } _ => format!("Quality check flagged: {code}"), } } fn has_ownership_boundary_signal(working_set: &StageWorkingSet) -> bool { let text = (working_set.constraints.join(" ") + " " + &working_set.risks.join(" ") + " " + &working_set.goal_md) .to_lowercase(); OWNERSHIP_BOUNDARY_MARKERS.iter().any(|marker| text.contains(marker)) } fn lacks_vague_acceptance_guardrails(passes: &[PlanningPersonaPass]) -> bool { let mut has_any_criteria = false; let mut has_explicit = false; let mut has_vague = false; for pass in passes { if pass.acceptance_criteria.is_empty() { continue; } has_any_criteria = true; for criteria in &pass.acceptance_criteria { let c = criteria.to_lowercase(); if c.len() < 22 || c.contains("works") || c.contains("correctness") || c.contains("pass") { has_vague = true; } if contains_maintenance_signal(&c) { has_explicit = true; } } } if !has_any_criteria { return true; } has_vague && !has_explicit } fn has_correctness_only_optimization(passes: &[PlanningPersonaPass]) -> bool { let mut has_criteria = false; let mut all_short_term = true; let mut has_maintenance_signal = false; for pass in passes { for criteria in &pass.acceptance_criteria { let criteria = criteria.to_lowercase(); if criteria.trim().is_empty() { continue; } has_criteria = true; if contains_maintenance_signal(&criteria) { has_maintenance_signal = true; all_short_term = false; continue; } if !is_short_term_criterion(&criteria) { all_short_term = false; } } } has_criteria && all_short_term && !has_maintenance_signal } fn has_risky_shortcut_pattern(plan: &Plan) -> bool { let mut text = plan.goal_summary.to_lowercase(); for step in &plan.steps { if !step.title.is_empty() { text.push(' '); text.push_str(&step.title.to_lowercase()); } if !step.purpose.is_empty() { text.push(' '); text.push_str(&step.purpose.to_lowercase()); } if !step.notes.is_empty() { text.push(' '); text.push_str(&step.notes.to_lowercase()); } } RISKY_SHORTCUT_MARKERS.iter().any(|marker| text.contains(marker)) } fn has_risky_shortcut_pattern_in_passes(passes: &[PlanningPersonaPass]) -> bool { let mut text = String::new(); for pass in passes { text.push_str(&pass.intent.to_lowercase()); text.push(' '); text.push_str(&pass.risks.join(" ").to_lowercase()); text.push(' '); text.push_str(&pass.constraints.join(" ").to_lowercase()); text.push(' '); } RISKY_SHORTCUT_MARKERS.iter().any(|marker| text.contains(marker)) } fn has_missing_iteration_review_context(state: &ControllerState, evidence: String) -> bool { if state.iteration == 0 { return false; } let evidence = evidence.to_lowercase(); !ITERATION_CONTEXT_MARKERS.iter().any(|marker| evidence.contains(marker)) } fn personaevidence_to_text(passes: &[PlanningPersonaPass]) -> String { let mut out = String::new(); for pass in passes { out.push_str(&pass.acceptance_criteria.join(" ").to_lowercase()); out.push(' '); out.push_str(&pass.constraints.join(" ").to_lowercase()); out.push(' '); out.push_str(&pass.risks.join(" ").to_lowercase()); out.push(' '); } out } fn emit_persona_stage_observation( event_tx: &Sender, stage_label: &str, persona: &PlanningPersona, pass: &PlanningPersonaPass, response: &PlannerResponse, contract: &PlanningContract, ) { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Planner, stream: SessionStream::Status, title: "Persona stage".to_string(), tag: Some(stage_label.to_string()), body: format!( "v{} template=v{} {:?} stage={} kind={} intent=\"{}\" constraints={} risks={} acceptance={} evidence=(f:{}/a:{}/q:{})", contract.contract_version, PLANNING_PERSONA_TEMPLATE_VERSION, persona, stage_label, response.kind, pass.intent, pass.constraints.len(), pass.risks.len(), pass.acceptance_criteria.len(), pass.evidence.facts.len(), pass.evidence.assumptions.len(), pass.evidence.questions.len() ), run_id: repo::next_run_id(), })); } fn emit_stage_transition_observation( event_tx: &Sender, stage_label: &str, persona: &PlanningPersona, pass: &PlanningPersonaPass, response: &PlannerResponse, contract: &PlanningContract, ) { let _ = event_tx.send(AppEvent::Session(SessionEntry { source: SessionSource::Planner, stream: SessionStream::Status, title: stage_label.to_string(), tag: Some(format!("v{}", contract.contract_version)), body: format!( "template=v{} persona={:?} kind={} constraints={} risks={} acceptance={} evidence=f:{}/a:{}/q:{} intent=\"{}\"", PLANNING_PERSONA_TEMPLATE_VERSION, persona, response.kind, pass.constraints.len(), pass.risks.len(), pass.acceptance_criteria.len(), pass.evidence.facts.len(), pass.evidence.assumptions.len(), pass.evidence.questions.len(), pass.intent ), run_id: repo::next_run_id(), })); } fn build_contract_merge_rules(contract: &PlanningContract) -> String { if contract.conflict_rules.is_empty() { return "No explicit conflict rules configured.".to_string(); } contract .conflict_rules .iter() .map(|rule| format!("- {field}: {strategy:?}", field = rule.field, strategy = rule.strategy)) .collect::>() .join("\n") } fn is_short_term_criterion(criteria: &str) -> bool { CORRECTNESS_ONLY_MARKERS.iter().any(|marker| criteria.contains(marker)) || criteria.len() < 24 } fn contains_maintenance_signal(criteria: &str) -> bool { MAINTAINABILITY_MARKERS .iter() .any(|marker| criteria.contains(marker)) } fn merge_stage_pass( contract: &PlanningContract, working_set: &mut StageWorkingSet, persona: &PlanningPersona, response: &PlannerResponse, stage_pass: &PlanningPersonaPass, ) -> Result<()> { for rule in &contract.conflict_rules { match rule.field.as_str() { "goal_md" => { if rule.strategy == PlanningConflictStrategy::LatestStageWins { if let Some(goal_md) = &response.goal_md { if !goal_md.trim().is_empty() { working_set.goal_md = goal_md.clone(); working_set.plan_projection.goal_md_stage = persona.clone(); } } } } "standards_md" => { if rule.strategy == PlanningConflictStrategy::LatestStageWins { if let Some(standards_md) = &response.standards_md { if !standards_md.trim().is_empty() { working_set.standards_md = standards_md.clone(); working_set.plan_projection.standards_md_stage = persona.clone(); } } } } "plan" => { if rule.strategy == PlanningConflictStrategy::Replace { if let Some(plan) = &response.plan { working_set.plan = plan.clone(); working_set.plan_projection.plan_stage = persona.clone(); } } } "constraints" => { if rule.strategy == PlanningConflictStrategy::AppendUnique { append_unique(&mut working_set.constraints, &stage_pass.constraints); } } "risks" => { if rule.strategy == PlanningConflictStrategy::AppendUnique { append_unique(&mut working_set.risks, &stage_pass.risks); } } _ => {} } } Ok(()) } fn append_unique(target: &mut Vec, incoming: &[String]) { for item in incoming { if !item.trim().is_empty() && !target.contains(&item.trim().to_string()) { target.push(item.trim().to_string()); } } } fn extract_persona_pass<'a>( response: &'a PlannerResponse, persona: &PlanningPersona, ) -> Option { response .persona_passes .iter() .rev() .find(|pass| &pass.persona == persona) .cloned() } fn fallback_persona_pass(persona: &PlanningPersona) -> PlanningPersonaPass { PlanningPersonaPass { persona: persona.clone(), intent: "Refine the goal through a deterministic maintainable plan pass.".to_string(), constraints: vec![], risks: vec!["No explicit pass-level risks were returned.".to_string()], acceptance_criteria: vec!["Maintainability and execution clarity preserved.".to_string()], evidence: model::PlanningPersonaEvidence { facts: vec![], assumptions: vec![], questions: vec![], }, } } fn persona_instructions(persona: &PlanningPersona) -> (&'static str, &'static str, &'static str) { match persona { PlanningPersona::ProductOwner => ( "product-owner", "clarify value, non-functional constraints, and measurable outcomes", "ask at most one question if requirement is ambiguous", ), PlanningPersona::SeniorEngineer => ( "senior-engineer", "strengthen design and implementation quality, avoiding brittle or one-off fixes", "do not ask questions", ), PlanningPersona::SeniorMaintainer => ( "senior-maintainer", "optimize long-term iterability and reduce maintenance risk", "do not ask questions", ), } } pub(crate) fn canonicalize_persona_passes( contract: &PlanningContract, passes: Vec, ) -> Vec { let mut canonical = Vec::new(); for persona in &contract.ordered_personas { if let Some(pass) = passes .iter() .rev() .find(|pass| &pass.persona == persona) .cloned() { if !canonical .iter() .any(|known: &PlanningPersonaPass| known.persona == pass.persona) { canonical.push(pass); } } } canonical } pub(crate) fn reorder_persona_names(contract: &PlanningContract) -> Vec<&'static str> { let mut names = Vec::new(); for persona in &contract.ordered_personas { names.push(match persona { PlanningPersona::ProductOwner => "product-owner", PlanningPersona::SeniorEngineer => "senior-engineer", PlanningPersona::SeniorMaintainer => "senior-maintainer", }); } names } #[cfg(test)] mod tests { use super::*; use std::sync::mpsc; #[test] fn planning_schema_requires_all_declared_keys() { let schema = planning_schema(); let contract_schema = json!([ "kind", "question", "goal_md", "standards_md", "plan", "planning_contract_version", "contract", "persona_passes", "single_pass_projection" ]); assert_eq!(schema["required"], contract_schema); assert_eq!(schema["type"], "object"); } #[test] fn parse_planning_response_maps_legacy_payload_for_compatibility() { let raw = r#"{ "kind":"final", "question":null, "goal_md":"goal", "standards_md":"standards", "plan":{"version":1,"goal_summary":"goal","steps":[]} }"#; let response = parse_planning_response(raw).expect("parse legacy plan"); assert_eq!(response.planning_contract_version, 0); assert!(response.contract.is_some()); assert_eq!( response .single_pass_projection .expect("single-pass projection") .goal_md_stage, crate::model::PlanningPersona::SeniorMaintainer ); } fn sample_pass( persona: PlanningPersona, intent: &str, constraints: &[&str], risks: &[&str], ) -> PlanningPersonaPass { PlanningPersonaPass { persona, intent: intent.to_string(), constraints: constraints.iter().map(ToString::to_string).collect(), risks: risks.iter().map(ToString::to_string).collect(), acceptance_criteria: vec!["maintainable execution".to_string()], evidence: model::PlanningPersonaEvidence { facts: vec!["existing artifact".to_string()], assumptions: vec!["no blocking dependencies".to_string()], questions: vec![], }, } } fn sample_plan(goal_summary: &str) -> Plan { Plan { version: 1, goal_summary: goal_summary.to_string(), steps: vec![], } } fn sample_response( kind: &str, goal_md: Option<&str>, standards_md: Option<&str>, plan: Option, ) -> PlannerResponse { PlannerResponse { kind: kind.to_string(), question: None, goal_md: goal_md.map(str::to_string), standards_md: standards_md.map(str::to_string), plan, planning_contract_version: 1, contract: None, persona_passes: vec![], single_pass_projection: None, quality_gate: model::PlanningQualityGate::default(), } } #[test] fn canonicalize_persona_passes_preserves_contract_order() { let contract = PlanningContract::default(); let passes = vec![ sample_pass( PlanningPersona::SeniorMaintainer, "tail pass first", &["traceable checks"], &["maintenance debt"], ), sample_pass( PlanningPersona::ProductOwner, "first pass", &["value constraints"], &["clarity debt"], ), sample_pass( PlanningPersona::SeniorEngineer, "second pass", &["design constraints"], &["complexity debt"], ), sample_pass( PlanningPersona::ProductOwner, "owner refinement", &[], &[], ), ]; let canonical = canonicalize_persona_passes(&contract, passes); assert_eq!(canonical.len(), 3); assert_eq!(canonical[0].persona, PlanningPersona::ProductOwner); assert_eq!(canonical[0].intent, "owner refinement"); assert_eq!(canonical[1].persona, PlanningPersona::SeniorEngineer); assert_eq!(canonical[2].persona, PlanningPersona::SeniorMaintainer); } #[test] fn merge_stage_pass_applies_conflict_rules() { let contract = PlanningContract::default(); let mut working_set = StageWorkingSet { goal_md: "original goal".to_string(), standards_md: "original standards".to_string(), plan: sample_plan("original plan"), constraints: vec![], risks: vec![], plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; let owner_pass = sample_pass( PlanningPersona::ProductOwner, "owner intent", &["reuse existing abstraction", "limit coupling"], &["coupling drift"], ); let owner_response = sample_response( "final", Some("goal v1"), Some("standards v1"), Some(sample_plan("plan v1")), ); merge_stage_pass(&contract, &mut working_set, &PlanningPersona::ProductOwner, &owner_response, &owner_pass) .expect("owner merge"); assert_eq!(working_set.plan_projection.goal_md_stage, PlanningPersona::ProductOwner); assert_eq!(working_set.constraints, vec!["reuse existing abstraction", "limit coupling"]); let maintainer_pass = sample_pass( PlanningPersona::SeniorMaintainer, "maintainer intent", &["limit coupling", "add safeguards"], &["deprecation risk"], ); let maintainer_response = sample_response( "final", Some("goal v2"), Some("standards v2"), Some(sample_plan("plan v2")), ); merge_stage_pass( &contract, &mut working_set, &PlanningPersona::SeniorMaintainer, &maintainer_response, &maintainer_pass, ) .expect("maintainer merge"); assert_eq!(working_set.goal_md, "goal v2"); assert_eq!(working_set.standards_md, "standards v2"); assert_eq!(working_set.plan.goal_summary, "plan v2"); assert_eq!( working_set.constraints, vec!["reuse existing abstraction", "limit coupling", "add safeguards"] ); assert_eq!( working_set.risks, vec!["coupling drift", "deprecation risk"] ); assert_eq!( working_set.plan_projection.plan_stage, PlanningPersona::SeniorMaintainer ); } #[test] fn missing_persona_pass_falls_back_to_deterministic_default() { let response = sample_response("final", Some("goal"), Some("standards"), None); let fallback = extract_persona_pass(&response, &PlanningPersona::SeniorEngineer) .unwrap_or_else(|| fallback_persona_pass(&PlanningPersona::SeniorEngineer)); assert_eq!(fallback.persona, PlanningPersona::SeniorEngineer); assert!(!fallback.risks.is_empty()); } #[test] fn quality_gate_rejects_risky_shortcuts() { let contract = PlanningContract::default(); let mut working_set = StageWorkingSet { goal_md: "goal".to_string(), standards_md: "standards".to_string(), plan: Plan { version: 1, goal_summary: "Refactor with quick fix".to_string(), steps: vec![], }, constraints: vec!["use existing abstraction".to_string()], risks: vec![], plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; let passes = vec![ sample_pass( PlanningPersona::ProductOwner, "owner pass", &["define ownership"], &["correctness first"], ), sample_pass( PlanningPersona::SeniorEngineer, "engineer pass", &["keep behavior stable"], &["temporary workaround acceptable"], ), sample_pass( PlanningPersona::SeniorMaintainer, "maintainer pass", &["no hardcode"], &["avoid temporary hacks"], ), ]; let gate = evaluate_quality_gate(&ControllerState::default(), &working_set, &passes); assert_eq!(gate.decision_code, model::PlanningQualityDecisionCode::Blocked); assert!(gate.rationale_codes.contains(&"risky-shortcut-pattern".to_string())); assert_eq!(gate.rationale.len(), gate.rationale_codes.len()); } #[test] fn quality_gate_allows_iteration_aware_maintainable_goals() { let contract = PlanningContract::default(); let mut state = ControllerState::default(); state.iteration = 2; let _ = contract; let working_set = StageWorkingSet { goal_md: "goal".to_string(), standards_md: "standards with iteration context".to_string(), plan: Plan { version: 1, goal_summary: "Refactor interface ownership".to_string(), steps: vec![], }, constraints: vec![ "Respect module ownership boundaries".to_string(), "Define rollback path for future iterations".to_string(), ], risks: vec!["coupling drift".to_string()], plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; let passes = vec![ sample_pass( PlanningPersona::ProductOwner, "owner pass", &["define ownership", "long-term stability"], &["no coupling"], ), sample_pass( PlanningPersona::SeniorEngineer, "engineer pass", &["keep boundaries explicit"], &["migration scheduling"], ), sample_pass( PlanningPersona::SeniorMaintainer, "maintainer pass", &["review before next iteration"], &["migration debt tracking"], ), ]; let gate = evaluate_quality_gate(&state, &working_set, &passes); assert_eq!(gate.decision_code, model::PlanningQualityDecisionCode::Accept); assert!(gate.rationale.contains( &"plan passes maintainability and iteration-aware quality gates".to_string() )); } #[test] fn build_persona_prompt_is_versioned_and_stage_ordered() { let config = TaskConfig::default_for("longview-planner"); let state = ControllerState::default(); let plan = Plan::default(); let contract = PlanningContract::default(); let product_prompt = build_persona_planning_prompt( &config, &state, "# Goal\nShip value.", "## Standards\n", "Latest input", &plan, &contract, &PlanningPersona::ProductOwner, true, ); assert!(product_prompt.contains( "Chain template version: planning-persona-chain-v1" )); assert!(product_prompt.contains( "Stages must always run in order: product-owner -> senior-engineer -> senior-maintainer." )); assert!(product_prompt.contains("Question policy:")); assert!(product_prompt.contains("ask at most one question")); let engineer_prompt = build_persona_planning_prompt( &config, &state, "# Goal\nShip value.", "## Standards\n", "Latest input", &plan, &contract, &PlanningPersona::SeniorEngineer, false, ); assert!(engineer_prompt.contains("Question policy: do not ask questions")); } #[test] fn quality_gate_downgrades_when_ownership_boundaries_are_missing() { let working_set = StageWorkingSet { goal_md: "goal".to_string(), standards_md: "standards".to_string(), plan: Plan { version: 1, goal_summary: "Refactor command input".to_string(), steps: vec![], }, constraints: vec![], risks: vec![], plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; let passes = vec![ sample_pass( PlanningPersona::ProductOwner, "owner pass", &["clarify user outcomes"], &["migration window"], ), sample_pass( PlanningPersona::SeniorEngineer, "engineer pass", &["keep behavior safe"], &["performance coupling"], ), sample_pass( PlanningPersona::SeniorMaintainer, "maintainer pass", &["keep maintenance burden explicit"], &["explicit module ownership debt"], ), ]; let gate = evaluate_quality_gate(&ControllerState::default(), &working_set, &passes); assert_eq!(gate.decision_code, model::PlanningQualityDecisionCode::Downgraded); assert!(gate.rationale_codes.contains(&"missing-ownership-boundaries".to_string())); } #[test] fn quality_gate_downgrades_when_acceptance_is_correctness_only() { let working_set = StageWorkingSet { goal_md: "goal".to_string(), standards_md: "standards".to_string(), plan: Plan { version: 1, goal_summary: "Refactor interface ownership".to_string(), steps: vec![], }, constraints: vec!["module boundary".to_string()], risks: vec![], plan_projection: LegacyOutputProjection { goal_md_stage: PlanningPersona::ProductOwner, standards_md_stage: PlanningPersona::ProductOwner, plan_stage: PlanningPersona::ProductOwner, }, }; let passes = vec![ PlanningPersonaPass { persona: PlanningPersona::ProductOwner, intent: "owner pass".to_string(), constraints: vec!["module boundary".to_string()], risks: vec![], acceptance_criteria: vec!["All tests pass".to_string()], evidence: model::PlanningPersonaEvidence { facts: vec![], assumptions: vec![], questions: vec![], }, }, PlanningPersonaPass { persona: PlanningPersona::SeniorEngineer, intent: "engineer pass".to_string(), constraints: vec!["release latency".to_string()], risks: vec!["build fragility".to_string()], acceptance_criteria: vec!["Passes 10 test cases".to_string()], evidence: model::PlanningPersonaEvidence { facts: vec![], assumptions: vec![], questions: vec![], }, }, PlanningPersonaPass { persona: PlanningPersona::SeniorMaintainer, intent: "maintainer pass".to_string(), constraints: vec!["stability checks".to_string()], risks: vec!["time constraint".to_string()], acceptance_criteria: vec!["Working on existing matrix".to_string()], evidence: model::PlanningPersonaEvidence { facts: vec![], assumptions: vec![], questions: vec![], }, }, ]; let gate = evaluate_quality_gate(&ControllerState::default(), &working_set, &passes); assert_eq!(gate.decision_code, model::PlanningQualityDecisionCode::Downgraded); assert!(gate.rationale_codes.contains(&"vague-acceptance-criteria".to_string())); assert!(gate.rationale_codes.contains(&"correctness-only-optimization".to_string())); } #[test] fn quality_gate_annotation_mentions_maintenance_guardrails_for_downgrade() { let gate = model::PlanningQualityGate { quality_gate_version: 1, decision_code: model::PlanningQualityDecisionCode::Downgraded, rationale_codes: vec![ "vague-acceptance-criteria".to_string(), "correctness-only-optimization".to_string(), ], rationale: vec![], }; let annotations = quality_gate_annotations(&gate); assert_eq!(annotations.len(), 2); assert!(annotations .iter() .any(|a| a.contains("Replace ambiguous success criteria"))); assert!(annotations .iter() .any(|a| a.contains("Balance correctness goals with architecture and maintenance acceptance criteria"))); } #[test] fn emit_persona_stage_observation_captures_stage_index_and_persona() { let (event_tx, event_rx) = mpsc::channel(); let pass = sample_pass( PlanningPersona::SeniorEngineer, "engineer intent", &["limit coupling"], &["migration debt"], ); let response = sample_response("final", Some("goal"), Some("standards"), None); emit_persona_stage_observation( &event_tx, "stage-2", &PlanningPersona::SeniorEngineer, &pass, &response, &PlanningContract::default(), ); let event = event_rx.recv().expect("Persona stage event"); match event { AppEvent::Session(entry) => { assert_eq!(entry.title, "Persona stage"); assert_eq!(entry.tag, Some("stage-2".to_string())); assert!(entry.body.contains("v1")); assert!(entry.body.contains("SeniorEngineer")); } _ => panic!("expected session event"), } emit_stage_transition_observation( &event_tx, "stage-2", &PlanningPersona::SeniorEngineer, &pass, &response, &PlanningContract::default(), ); let event = event_rx.recv().expect("stage-2 transition event"); match event { AppEvent::Session(entry) => { assert_eq!(entry.title, "stage-2"); assert_eq!(entry.tag, Some("v1".to_string())); assert!(entry.body.contains("kind=final")); assert!(entry.body.contains("SeniorEngineer")); } _ => panic!("expected session event"), } } }