1563 lines
54 KiB
Rust
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"),
|
|
}
|
|
}
|
|
}
|