magi-code 0.64.0

Repository-aware CLI coding agent for terminal work
Documentation
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum SubagentOutputPhase {
    Plan,
    Research,
    Implement,
    Review,
    Document,
}

impl SubagentOutputPhase {
    pub(crate) fn from_schema_ref(value: &str) -> Option<Self> {
        match value {
            "plan" => Some(Self::Plan),
            "research" => Some(Self::Research),
            "implement" => Some(Self::Implement),
            "review" => Some(Self::Review),
            "document" => Some(Self::Document),
            _ => None,
        }
    }

    fn phase_name(self) -> &'static str {
        match self {
            Self::Plan => "PLAN",
            Self::Research => "RESEARCH",
            Self::Implement => "IMPLEMENT",
            Self::Review => "REVIEW",
            Self::Document => "DOCUMENT",
        }
    }

    fn status_values(self) -> &'static [&'static str] {
        match self {
            Self::Plan => &["READY", "NEEDS_REVISION", "BLOCKED"],
            Self::Research => &["COMPLETE", "PARTIAL", "BLOCKED"],
            Self::Implement => &["COMPLETE", "PARTIAL", "BLOCKED"],
            Self::Review => &["COMPLETE", "PARTIAL", "BLOCKED"],
            Self::Document => &["COMPLETE", "PARTIAL", "BLOCKED"],
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SubagentOutputSchemaRef {
    Builtin(SubagentOutputPhase),
    Inline(Value),
    None,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SchemaValidationError {
    pub(crate) instance_path: String,
    pub(crate) schema_path: String,
    pub(crate) message: String,
}

pub(crate) fn default_phase_for_identity(identity: &str) -> Option<SubagentOutputPhase> {
    match identity {
        "tars-plan-creation" => Some(SubagentOutputPhase::Plan),
        "tars-research-investigation" => Some(SubagentOutputPhase::Research),
        "tars-code-writing-execution" => Some(SubagentOutputPhase::Implement),
        "tars-execution-review" | "tars-plan-review" | "tars-plan-audit" => {
            Some(SubagentOutputPhase::Review)
        }
        "tars-document-authoring" => Some(SubagentOutputPhase::Document),
        _ => None,
    }
}

pub(crate) fn schema_for_ref(schema_ref: &SubagentOutputSchemaRef) -> Option<Value> {
    match schema_ref {
        SubagentOutputSchemaRef::Builtin(phase) => Some(builtin_schema(*phase)),
        SubagentOutputSchemaRef::Inline(schema) => Some(schema.clone()),
        SubagentOutputSchemaRef::None => None,
    }
}

pub(crate) fn builtin_schema(phase: SubagentOutputPhase) -> Value {
    let mut properties = common_properties(phase);
    let mut required = vec![
        "phase",
        "status",
        "summary",
        "artifacts",
        "verification",
        "risks",
    ];
    match phase {
        SubagentOutputPhase::Plan => {
            properties["plan_path"] = string_schema();
            properties["ready_for_implementation"] = json!({"type": "boolean"});
            properties["documentation"] = json!({
                "type": "object",
                "additionalProperties": false,
                "properties": {
                    "prd": string_schema(),
                    "adr": string_schema(),
                    "rfc": string_schema()
                },
                "required": ["prd", "adr", "rfc"]
            });
            properties["key_assumptions"] = string_array_schema();
            required.extend([
                "plan_path",
                "ready_for_implementation",
                "documentation",
                "key_assumptions",
            ]);
        }
        SubagentOutputPhase::Research => {
            properties["findings"] = json!({
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "claim": string_schema(),
                        "evidence": string_schema(),
                        "confidence": {"enum": ["observed", "inferred", "unknown"]}
                    },
                    "required": ["claim", "evidence", "confidence"]
                }
            });
            properties["open_questions"] = string_array_schema();
            required.extend(["findings", "open_questions"]);
        }
        SubagentOutputPhase::Implement => {
            properties["changed_files"] = string_array_schema();
            properties["tests_run"] = string_array_schema();
            properties["implementation_notes"] = string_array_schema();
            required.extend(["changed_files", "tests_run", "implementation_notes"]);
        }
        SubagentOutputPhase::Review => {
            properties["verdict"] = json!({"enum": ["PASS", "NEEDS_REVISION", "FAIL"]});
            properties["findings"] = json!({
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "severity": {"enum": ["critical", "high", "medium", "low"]},
                        "file": string_schema(),
                        "issue": string_schema(),
                        "recommendation": string_schema()
                    },
                    "required": ["severity", "file", "issue", "recommendation"]
                }
            });
            required.extend(["verdict", "findings"]);
        }
        SubagentOutputPhase::Document => {
            properties["documents"] = json!({
                "type": "array",
                "items": {
                    "type": "object",
                    "additionalProperties": false,
                    "properties": {
                        "path": string_schema(),
                        "status": string_schema(),
                        "change_summary": string_schema()
                    },
                    "required": ["path", "status", "change_summary"]
                }
            });
            properties["ready"] = json!({"type": "boolean"});
            required.extend(["documents", "ready"]);
        }
    }
    json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "additionalProperties": false,
        "properties": properties,
        "required": required
    })
}

pub(crate) fn validate_instance(
    schema: &Value,
    instance: &Value,
) -> Result<(), Vec<SchemaValidationError>> {
    let validator = jsonschema::validator_for(schema).map_err(|error| {
        vec![SchemaValidationError {
            instance_path: String::new(),
            schema_path: String::new(),
            message: format!("invalid output_schema: {error}"),
        }]
    })?;
    let errors = validator
        .iter_errors(instance)
        .map(|error| SchemaValidationError {
            instance_path: error.instance_path().to_string(),
            schema_path: error.schema_path().to_string(),
            message: error.to_string(),
        })
        .collect::<Vec<_>>();
    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

fn common_properties(phase: SubagentOutputPhase) -> Value {
    json!({
        "phase": {"const": phase.phase_name()},
        "status": {"enum": phase.status_values()},
        "summary": string_schema(),
        "artifacts": string_array_schema(),
        "verification": string_array_schema(),
        "risks": string_array_schema()
    })
}

fn string_schema() -> Value {
    json!({"type": "string"})
}

fn string_array_schema() -> Value {
    json!({"type": "array", "items": string_schema()})
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn custodian_phase_schemas_validate_examples() {
        let examples = vec![
            (
                SubagentOutputPhase::Plan,
                json!({
                    "phase": "PLAN",
                    "status": "READY",
                    "summary": "plan ready",
                    "artifacts": ["docs/plans/x.md"],
                    "verification": ["plan reviewed"],
                    "risks": [],
                    "plan_path": "docs/plans/x.md",
                    "ready_for_implementation": true,
                    "documentation": {"prd": "skipped", "adr": "skipped", "rfc": "skipped"},
                    "key_assumptions": ["scope fixed"]
                }),
            ),
            (
                SubagentOutputPhase::Research,
                json!({
                    "phase": "RESEARCH",
                    "status": "COMPLETE",
                    "summary": "findings done",
                    "artifacts": [],
                    "verification": ["code read"],
                    "risks": [],
                    "findings": [{"claim": "x", "evidence": "file", "confidence": "observed"}],
                    "open_questions": []
                }),
            ),
            (
                SubagentOutputPhase::Implement,
                json!({
                    "phase": "IMPLEMENT",
                    "status": "COMPLETE",
                    "summary": "implemented",
                    "artifacts": [],
                    "verification": ["cargo test"],
                    "risks": [],
                    "changed_files": ["src/lib.rs"],
                    "tests_run": ["cargo test"],
                    "implementation_notes": ["done"]
                }),
            ),
            (
                SubagentOutputPhase::Review,
                json!({
                    "phase": "REVIEW",
                    "status": "COMPLETE",
                    "summary": "reviewed",
                    "artifacts": [],
                    "verification": ["diff read"],
                    "risks": [],
                    "verdict": "PASS",
                    "findings": [{"severity": "low", "file": "src/lib.rs", "issue": "none", "recommendation": "none"}]
                }),
            ),
            (
                SubagentOutputPhase::Document,
                json!({
                    "phase": "DOCUMENT",
                    "status": "COMPLETE",
                    "summary": "documented",
                    "artifacts": ["docs/x.md"],
                    "verification": ["read docs"],
                    "risks": [],
                    "documents": [{"path": "docs/x.md", "status": "updated", "change_summary": "changed"}],
                    "ready": true
                }),
            ),
        ];

        for (phase, example) in examples {
            validate_instance(&builtin_schema(phase), &example).unwrap();
        }
    }

    #[test]
    fn subagent_output_schema_rejects_missing_required_field() {
        let invalid = json!({
            "phase": "IMPLEMENT",
            "status": "COMPLETE",
            "summary": "missing arrays",
            "artifacts": [],
            "verification": [],
            "risks": []
        });

        let errors = validate_instance(&builtin_schema(SubagentOutputPhase::Implement), &invalid)
            .unwrap_err();

        assert!(
            errors
                .iter()
                .any(|error| error.message.contains("changed_files"))
        );
    }
}