planwarden 0.4.0

CLI planning enforcer for AI agents
Documentation
use serde::Serialize;

use crate::review::PlanKind;

#[derive(Debug, Serialize)]
pub struct ReviewSchema {
    pub command: String,
    pub summary: String,
    pub notes: Vec<String>,
    pub fields: Vec<FieldSpec>,
    pub signals: Vec<FieldSpec>,
    pub concern_rule: ConcernRuleSpec,
    pub example_path: String,
}

#[derive(Debug, Serialize)]
pub struct FieldSpec {
    pub name: String,
    pub required: bool,
    pub kind: String,
    pub description: String,
}

#[derive(Debug, Serialize)]
pub struct ConcernRuleSpec {
    pub shape: String,
    pub required_concerns: Vec<String>,
    pub rules: Vec<String>,
}

pub fn review_schema(kind: PlanKind) -> ReviewSchema {
    let label = match kind {
        PlanKind::Plan => "plan",
        PlanKind::Task => "task",
    };
    let summary = match kind {
        PlanKind::Plan => "Validate a multi-slice plan and normalize it into a durable plan file.",
        PlanKind::Task => "Validate one execution slice and normalize it into a durable task plan.",
    };

    ReviewSchema {
        command: format!("planwarden review {label}"),
        summary: summary.to_string(),
        notes: vec![
            "The agent is expected to investigate first, then send structured findings instead of free-form prose.".to_string(),
            "Plan and task currently share the same payload shape; the difference is scope and the resulting item IDs.".to_string(),
            "If a concern does not apply, the agent must say so explicitly and justify it.".to_string(),
            "After `create`, the agent should call `planwarden review-next <plan-file> --format text`, show only that section in chat, ask the user to approve it or raise concerns, and only then advance review. Use a question tool if the host has one; otherwise ask in plain chat.".to_string(),
            "Do not dump or summarize the full plan while review is still section-by-section. If the user raises concerns, discuss or revise the plan first, then continue review.".to_string(),
            "Hosts that call `planwarden review-next <plan-file> --format json` receive an `approval` block with a prompt, response options, an advance command, and host-aware naming hints so they do not need to scrape the text renderer.".to_string(),
        ],
        fields: vec![
            field("title", false, "string", "Optional display title; defaults to `goal`."),
            field("goal", true, "string", "One clear outcome statement."),
            field("facts", false, "string[]", "Concrete repo findings the agent already verified."),
            field("constraints", false, "string[]", "Hard limits or non-negotiables."),
            field(
                "acceptance_criteria",
                true,
                "string[]",
                "Top-level success conditions for the whole plan.",
            ),
            field(
                "unknowns",
                false,
                "string[]",
                "Real unresolved decisions that should turn into follow-up questions.",
            ),
            field("risks", false, "string[]", "Material implementation or rollout risks."),
            field(
                "proposed_slices",
                true,
                "slice[]",
                "At least one execution slice with title, summary, dependencies, and acceptance_criteria.",
            ),
            field(
                "concerns",
                true,
                "object",
                "Applicability plus approach/reason for rollback, security, auth, authz, decoupling, tests, and bugfix red proof.",
            ),
        ],
        signals: vec![
            field("bugfix", true, "boolean", "Set true for bugfix/debugging work."),
            field("user_visible", true, "boolean", "Set true when behavior changes in a user-facing surface."),
            field(
                "touches_authentication",
                true,
                "boolean",
                "Set true when login/session mechanics are affected.",
            ),
            field(
                "touches_authorization",
                true,
                "boolean",
                "Set true when roles/permissions/data access checks are affected.",
            ),
            field(
                "touches_sensitive_data",
                true,
                "boolean",
                "Set true when the work touches secrets, PII, tenant boundaries, or restricted records.",
            ),
            field(
                "touches_external_boundary",
                true,
                "boolean",
                "Set true when external APIs, webhooks, queues, or other trust boundaries are involved.",
            ),
            field(
                "touches_database_schema",
                true,
                "boolean",
                "Set true when migrations or schema changes are involved.",
            ),
            field(
                "cross_cutting_change",
                true,
                "boolean",
                "Set true when the work reaches across multiple modules or layers.",
            ),
        ],
        concern_rule: ConcernRuleSpec {
            shape: "Each concern is `{ applicable: boolean, reason?: string, approach?: string }`.".to_string(),
            required_concerns: vec![
                "rollback".to_string(),
                "security".to_string(),
                "authentication".to_string(),
                "authorization".to_string(),
                "decoupling".to_string(),
                "tests.unit".to_string(),
                "tests.integration".to_string(),
                "tests.regression".to_string(),
                "tests.smoke".to_string(),
                "bugfix_red".to_string(),
            ],
            rules: vec![
                "If `applicable` is true, provide `approach`.".to_string(),
                "If `applicable` is false, provide `reason`.".to_string(),
                "Bugfix work must keep `bugfix_red.applicable = true`; otherwise review blocks.".to_string(),
                "User-visible work should include regression or smoke coverage; otherwise review pushes back.".to_string(),
                "Signals and concerns must agree. For example, touching authorization cannot mark authorization review as not applicable.".to_string(),
            ],
        },
        example_path: "examples/review-plan.json".to_string(),
    }
}

pub fn render_review_schema_text(schema: &ReviewSchema) -> String {
    let mut output = String::new();
    output.push_str(&format!("{}\n", schema.command));
    output.push_str(&format!("Purpose: {}\n\n", schema.summary));

    output.push_str("Top-level fields:\n");
    for field in &schema.fields {
        let required = if field.required {
            "required"
        } else {
            "optional"
        };
        output.push_str(&format!(
            "- {} ({}, {}): {}\n",
            field.name, field.kind, required, field.description
        ));
    }

    output.push_str("\nSignals:\n");
    for field in &schema.signals {
        output.push_str(&format!("- {}: {}\n", field.name, field.description));
    }

    output.push_str("\nConcern rule:\n");
    output.push_str(&format!("- {}\n", schema.concern_rule.shape));
    for rule in &schema.concern_rule.rules {
        output.push_str(&format!("- {}\n", rule));
    }

    output.push_str("\nNotes:\n");
    for note in &schema.notes {
        output.push_str(&format!("- {}\n", note));
    }

    output.push_str(&format!("\nExample payload: {}\n", schema.example_path));
    output
}

fn field(name: &str, required: bool, kind: &str, description: &str) -> FieldSpec {
    FieldSpec {
        name: name.to_string(),
        required,
        kind: kind.to_string(),
        description: description.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::{render_review_schema_text, review_schema};
    use crate::review::PlanKind;

    #[test]
    fn plan_schema_mentions_required_fields() {
        let schema = review_schema(PlanKind::Plan);
        assert_eq!(schema.command, "planwarden review plan");
        assert!(
            schema
                .fields
                .iter()
                .any(|field| field.name == "goal" && field.required)
        );
        assert!(
            schema
                .fields
                .iter()
                .any(|field| field.name == "proposed_slices"
                    && !field.description.contains("estimated_minutes"))
        );
    }

    #[test]
    fn schema_text_is_agent_facing() {
        let output = render_review_schema_text(&review_schema(PlanKind::Task));
        assert!(output.contains("planwarden review task"));
        assert!(output.contains("Top-level fields"));
        assert!(output.contains("Example payload"));
        assert!(output.contains("planwarden review-next <plan-file> --format text"));
    }
}