Files
codex-controller-loop/src/planning/forwarder.rs
eric ebb6b488fe
Some checks failed
distribution-gate / distribution-gate (push) Failing after 1m56s
feat: 0.1.0
2026-04-04 18:41:34 +02:00

1563 lines
54 KiB
Rust

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<String>,
risks: Vec<String>,
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<AppEvent>,
) -> Result<PlannerResponse> {
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<AppEvent>,
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<PlannerResponse> {
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::<Vec<_>>();
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<String> {
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<AppEvent>,
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<AppEvent>,
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::<Vec<_>>()
.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<String>, 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<PlanningPersonaPass> {
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<PlanningPersonaPass>,
) -> Vec<PlanningPersonaPass> {
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<Plan>,
) -> 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"),
}
}
}