Skip to main content

harn_modules/
personas.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use harn_parser::{Attribute, DictEntry, Node, SNode};
6use serde::{Deserialize, Serialize};
7use std::str::FromStr;
8
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
10pub struct PersonaManifestDocument {
11    #[serde(default)]
12    pub personas: Vec<PersonaManifestEntry>,
13}
14
15/// A persona's declared output style — how it should shape its prose (tone,
16/// verbosity, formatting). Accepts either a bare string (a named style) or a
17/// table with `name` and/or `instructions`. This is the persona-manifest field
18/// behind Burin's output-style surface; Harn owns the declaration + accessor,
19/// Burin owns any editor/workbench UI over it.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
21pub struct PersonaOutputStyle {
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub name: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub instructions: Option<String>,
26}
27
28impl PersonaOutputStyle {
29    /// Build a style from a bare name (the `output_style = "concise"` form).
30    pub fn from_name(name: impl Into<String>) -> Self {
31        Self {
32            name: Some(name.into()),
33            instructions: None,
34        }
35    }
36
37    /// True when the style carries no name and no instructions.
38    pub fn is_empty(&self) -> bool {
39        self.name.is_none() && self.instructions.is_none()
40    }
41}
42
43impl<'de> Deserialize<'de> for PersonaOutputStyle {
44    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
45    where
46        D: serde::Deserializer<'de>,
47    {
48        // Accept `output_style = "concise"` (a named style) or a table with
49        // `name` / `instructions`. Unknown table keys are rejected so a typo
50        // surfaces rather than being silently dropped.
51        #[derive(Deserialize)]
52        #[serde(untagged)]
53        enum Repr {
54            Name(String),
55            Table {
56                #[serde(default)]
57                name: Option<String>,
58                #[serde(default)]
59                instructions: Option<String>,
60            },
61        }
62        Ok(match Repr::deserialize(deserializer)? {
63            Repr::Name(name) => PersonaOutputStyle::from_name(name),
64            Repr::Table { name, instructions } => PersonaOutputStyle { name, instructions },
65        })
66    }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct PersonaManifestEntry {
71    #[serde(default)]
72    pub name: Option<String>,
73    #[serde(default)]
74    pub version: Option<String>,
75    #[serde(default)]
76    pub description: Option<String>,
77    #[serde(default, alias = "entry", alias = "entry_pipeline")]
78    pub entry_workflow: Option<String>,
79    #[serde(default)]
80    pub tools: Vec<String>,
81    #[serde(default)]
82    pub capabilities: Vec<String>,
83    #[serde(default, alias = "tier", alias = "autonomy")]
84    pub autonomy_tier: Option<PersonaAutonomyTier>,
85    #[serde(default, alias = "receipts")]
86    pub receipt_policy: Option<PersonaReceiptPolicy>,
87    #[serde(default)]
88    pub triggers: Vec<String>,
89    #[serde(default)]
90    pub schedules: Vec<String>,
91    #[serde(default)]
92    pub model_policy: PersonaModelPolicy,
93    #[serde(default)]
94    pub budget: PersonaBudget,
95    #[serde(default)]
96    pub handoffs: Vec<String>,
97    #[serde(default)]
98    pub context_packs: Vec<String>,
99    #[serde(default, alias = "eval_packs")]
100    pub evals: Vec<String>,
101    #[serde(default)]
102    pub owner: Option<String>,
103    #[serde(default)]
104    pub package_source: PersonaPackageSource,
105    #[serde(default)]
106    pub rollout_policy: PersonaRolloutPolicy,
107    #[serde(default)]
108    pub steps: Vec<PersonaStepMetadata>,
109    /// Per-stage tool-surface narrowing. Each stage names a `@step` and
110    /// declares the tools / side-effect ceiling enforced while that step
111    /// runs.
112    #[serde(default)]
113    pub stages: Vec<PersonaStageDecl>,
114    /// How this persona should shape its output prose. A bare string names a
115    /// style; a table carries `name` and/or inline `instructions`.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub output_style: Option<PersonaOutputStyle>,
118    #[serde(flatten, default)]
119    pub extra: BTreeMap<String, toml::Value>,
120}
121
122/// Stage declaration carried on a `PersonaManifestEntry`.
123///
124/// Mirrors the runtime `harn_vm::StageDecl` shape so loaders can map
125/// directly. `allowed_tools = None` means "inherit the persona-level
126/// tool list"; `Some(vec![])` means "deny every tool while this stage is
127/// active".
128#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
129pub struct PersonaStageDecl {
130    pub name: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub allowed_tools: Option<Vec<String>>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub side_effect_level: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub max_iterations: Option<u32>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub on_exit: Option<PersonaStageExit>,
139    #[serde(flatten, default)]
140    pub extra: BTreeMap<String, toml::Value>,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
144pub struct PersonaStageExit {
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub on_complete: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub on_failure: Option<String>,
149    #[serde(flatten, default)]
150    pub extra: BTreeMap<String, toml::Value>,
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct PersonaStepMetadata {
155    pub name: String,
156    pub function: String,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub model: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub approval: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub receipt: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub error_boundary: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub retry: Option<PersonaStepRetry>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub budget: Option<PersonaStepBudget>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub line: Option<usize>,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct PersonaStepRetry {
175    pub max_attempts: u64,
176}
177
178/// Per-step token / cost ceiling. Either field is optional; whichever is
179/// set governs that dimension. Surfaced statically by `harn persona
180/// inspect --json` and consumed at runtime by `crates/harn-vm/src/step_runtime.rs`
181/// to short-circuit `llm_call` invocations before they exceed the limit.
182#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
183pub struct PersonaStepBudget {
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub max_tokens: Option<u64>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub max_usd: Option<f64>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum PersonaAutonomyTier {
193    Shadow,
194    Suggest,
195    ActWithApproval,
196    ActAuto,
197}
198
199impl PersonaAutonomyTier {
200    pub fn as_str(self) -> &'static str {
201        match self {
202            Self::Shadow => "shadow",
203            Self::Suggest => "suggest",
204            Self::ActWithApproval => "act_with_approval",
205            Self::ActAuto => "act_auto",
206        }
207    }
208}
209
210impl FromStr for PersonaAutonomyTier {
211    type Err = ();
212
213    fn from_str(value: &str) -> Result<Self, Self::Err> {
214        match value {
215            "shadow" => Ok(Self::Shadow),
216            "suggest" => Ok(Self::Suggest),
217            "act_with_approval" => Ok(Self::ActWithApproval),
218            "act_auto" => Ok(Self::ActAuto),
219            _ => Err(()),
220        }
221    }
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum PersonaReceiptPolicy {
227    #[default]
228    Optional,
229    Required,
230    Disabled,
231}
232
233impl PersonaReceiptPolicy {
234    pub fn as_str(self) -> &'static str {
235        match self {
236            Self::Optional => "optional",
237            Self::Required => "required",
238            Self::Disabled => "disabled",
239        }
240    }
241}
242
243impl FromStr for PersonaReceiptPolicy {
244    type Err = ();
245
246    fn from_str(value: &str) -> Result<Self, Self::Err> {
247        match value {
248            "optional" => Ok(Self::Optional),
249            "required" => Ok(Self::Required),
250            "disabled" => Ok(Self::Disabled),
251            "none" => Ok(Self::Disabled),
252            _ => Err(()),
253        }
254    }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
258pub struct PersonaModelPolicy {
259    #[serde(default)]
260    pub default_model: Option<String>,
261    #[serde(default)]
262    pub escalation_model: Option<String>,
263    #[serde(default)]
264    pub fallback_models: Vec<String>,
265    #[serde(default)]
266    pub reasoning_effort: Option<String>,
267    #[serde(flatten, default)]
268    pub extra: BTreeMap<String, toml::Value>,
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
272pub struct PersonaBudget {
273    #[serde(default)]
274    pub daily_usd: Option<f64>,
275    #[serde(default)]
276    pub hourly_usd: Option<f64>,
277    #[serde(default)]
278    pub run_usd: Option<f64>,
279    #[serde(default)]
280    pub frontier_escalations: Option<u32>,
281    #[serde(default)]
282    pub max_tokens: Option<u64>,
283    #[serde(default)]
284    pub max_runtime_seconds: Option<u64>,
285    #[serde(flatten, default)]
286    pub extra: BTreeMap<String, toml::Value>,
287}
288
289#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
290pub struct PersonaPackageSource {
291    #[serde(default)]
292    pub package: Option<String>,
293    #[serde(default)]
294    pub path: Option<String>,
295    #[serde(default)]
296    pub git: Option<String>,
297    #[serde(default)]
298    pub rev: Option<String>,
299    #[serde(flatten, default)]
300    pub extra: BTreeMap<String, toml::Value>,
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
304pub struct PersonaRolloutPolicy {
305    #[serde(default)]
306    pub mode: Option<String>,
307    #[serde(default)]
308    pub percentage: Option<u8>,
309    #[serde(default)]
310    pub cohorts: Vec<String>,
311    #[serde(flatten, default)]
312    pub extra: BTreeMap<String, toml::Value>,
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize)]
316pub struct ResolvedPersonaManifest {
317    pub manifest_path: PathBuf,
318    pub manifest_dir: PathBuf,
319    pub personas: Vec<PersonaManifestEntry>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
323pub struct PersonaValidationError {
324    pub manifest_path: PathBuf,
325    pub field_path: String,
326    pub message: String,
327}
328
329impl std::fmt::Display for PersonaValidationError {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        write!(
332            f,
333            "{} {}: {}",
334            self.manifest_path.display(),
335            self.field_path,
336            self.message
337        )
338    }
339}
340
341impl std::error::Error for PersonaValidationError {}
342
343#[derive(Debug, Clone, Default)]
344pub struct PersonaValidationContext {
345    pub known_capabilities: BTreeSet<String>,
346    pub known_tools: BTreeSet<String>,
347    pub known_names: BTreeSet<String>,
348}
349
350pub fn parse_persona_manifest_str(
351    source: &str,
352) -> Result<PersonaManifestDocument, toml::de::Error> {
353    let document = toml::from_str::<PersonaManifestDocument>(source)?;
354    if !document.personas.is_empty() {
355        return Ok(document);
356    }
357    let entry = toml::from_str::<PersonaManifestEntry>(source)?;
358    if entry.name.is_some()
359        || entry.description.is_some()
360        || entry.entry_workflow.is_some()
361        || !entry.tools.is_empty()
362        || !entry.capabilities.is_empty()
363    {
364        Ok(PersonaManifestDocument {
365            personas: vec![entry],
366        })
367    } else {
368        Ok(document)
369    }
370}
371
372pub fn parse_persona_manifest_file(path: &Path) -> Result<PersonaManifestDocument, String> {
373    let content = fs::read_to_string(path)
374        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
375    parse_persona_manifest_str(&content)
376        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
377}
378
379pub fn parse_persona_source_file(path: &Path) -> Result<PersonaManifestDocument, String> {
380    let content = fs::read_to_string(path)
381        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
382    parse_persona_source_str(&content)
383        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
384}
385
386pub fn parse_persona_source_str(source: &str) -> Result<PersonaManifestDocument, String> {
387    let program = harn_parser::parse_source(source).map_err(|error| error.to_string())?;
388    Ok(extract_personas_from_program(&program))
389}
390
391pub fn extract_personas_from_program(program: &[SNode]) -> PersonaManifestDocument {
392    let step_decls = collect_step_declarations(program);
393    let mut personas = Vec::new();
394    for snode in program {
395        let Node::AttributedDecl { attributes, inner } = &snode.node else {
396            continue;
397        };
398        let Some(persona_attr) = attributes.iter().find(|attr| attr.name == "persona") else {
399            continue;
400        };
401        let Node::FnDecl { name, body, .. } = &inner.node else {
402            continue;
403        };
404        let persona_name = attr_string(persona_attr, "name").unwrap_or_else(|| name.clone());
405        let mut seen = BTreeSet::new();
406        let mut steps = Vec::new();
407        for call_name in collect_called_functions(body) {
408            if !seen.insert(call_name.clone()) {
409                continue;
410            }
411            if let Some(step) = step_decls.get(&call_name) {
412                steps.push(step.clone());
413            }
414        }
415        personas.push(PersonaManifestEntry {
416            name: Some(persona_name),
417            description: Some(
418                attr_string(persona_attr, "description")
419                    .unwrap_or_else(|| "Source-declared persona".to_string()),
420            ),
421            entry_workflow: Some(name.clone()),
422            tools: attr_string_list(persona_attr, "tools"),
423            capabilities: {
424                let capabilities = attr_string_list(persona_attr, "capabilities");
425                if capabilities.is_empty() {
426                    vec!["project.test_commands".to_string()]
427                } else {
428                    capabilities
429                }
430            },
431            autonomy_tier: attr_string(persona_attr, "autonomy")
432                .as_deref()
433                .and_then(|value| PersonaAutonomyTier::from_str(value).ok())
434                .or(Some(PersonaAutonomyTier::Suggest)),
435            receipt_policy: attr_string(persona_attr, "receipts")
436                .as_deref()
437                .and_then(|value| PersonaReceiptPolicy::from_str(value).ok())
438                .or(Some(PersonaReceiptPolicy::Optional)),
439            steps,
440            stages: attr_stage_list(persona_attr),
441            output_style: attr_string(persona_attr, "output_style")
442                .map(PersonaOutputStyle::from_name),
443            ..PersonaManifestEntry::default()
444        });
445    }
446    PersonaManifestDocument { personas }
447}
448
449pub fn extract_step_metadata_from_program(program: &[SNode]) -> Vec<PersonaStepMetadata> {
450    collect_step_declarations(program).into_values().collect()
451}
452
453fn collect_step_declarations(program: &[SNode]) -> BTreeMap<String, PersonaStepMetadata> {
454    let mut steps = BTreeMap::new();
455    for snode in program {
456        let Node::AttributedDecl { attributes, inner } = &snode.node else {
457            continue;
458        };
459        let Some(step_attr) = attributes.iter().find(|attr| attr.name == "step") else {
460            continue;
461        };
462        let Node::FnDecl { name, .. } = &inner.node else {
463            continue;
464        };
465        steps.insert(
466            name.clone(),
467            PersonaStepMetadata {
468                name: attr_string(step_attr, "name").unwrap_or_else(|| name.clone()),
469                function: name.clone(),
470                model: attr_string(step_attr, "model"),
471                approval: attr_string(step_attr, "approval"),
472                receipt: attr_string(step_attr, "receipt"),
473                error_boundary: attr_string(step_attr, "error_boundary"),
474                retry: attr_retry(step_attr),
475                budget: attr_step_budget(step_attr),
476                line: Some(inner.span.line),
477            },
478        );
479    }
480    steps
481}
482
483fn attr_string(attr: &Attribute, key: &str) -> Option<String> {
484    attr.named_arg(key).and_then(node_string)
485}
486
487fn attr_string_list(attr: &Attribute, key: &str) -> Vec<String> {
488    let Some(value) = attr.named_arg(key) else {
489        return Vec::new();
490    };
491    let Node::ListLiteral(items) = &value.node else {
492        return Vec::new();
493    };
494    items.iter().filter_map(node_string).collect()
495}
496
497fn node_string(node: &SNode) -> Option<String> {
498    match &node.node {
499        Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
500            Some(value.clone())
501        }
502        _ => None,
503    }
504}
505
506fn attr_stage_list(attr: &Attribute) -> Vec<PersonaStageDecl> {
507    let Some(value) = attr.named_arg("stages") else {
508        return Vec::new();
509    };
510    let Node::ListLiteral(entries) = &value.node else {
511        return Vec::new();
512    };
513    let mut out = Vec::with_capacity(entries.len());
514    for entry in entries {
515        let Node::DictLiteral(fields) = &entry.node else {
516            continue;
517        };
518        let mut stage = PersonaStageDecl::default();
519        for dict_entry in fields {
520            let Some(key) = entry_key(&dict_entry.key) else {
521                continue;
522            };
523            match key {
524                "name" => {
525                    if let Some(name) = node_string(&dict_entry.value) {
526                        stage.name = name;
527                    }
528                }
529                "allowed_tools" => {
530                    if let Node::ListLiteral(items) = &dict_entry.value.node {
531                        let tools: Vec<String> = items.iter().filter_map(node_string).collect();
532                        stage.allowed_tools = Some(tools);
533                    }
534                }
535                "side_effect_level" => {
536                    stage.side_effect_level = node_string(&dict_entry.value);
537                }
538                "max_iterations" => {
539                    if let Node::IntLiteral(n) = dict_entry.value.node {
540                        if n >= 0 {
541                            stage.max_iterations = Some(n as u32);
542                        }
543                    }
544                }
545                _ => {}
546            }
547        }
548        if !stage.name.is_empty() {
549            out.push(stage);
550        }
551    }
552    out
553}
554
555fn attr_retry(attr: &Attribute) -> Option<PersonaStepRetry> {
556    let retry = attr.named_arg("retry")?;
557    let Node::DictLiteral(entries) = &retry.node else {
558        return None;
559    };
560    for entry in entries {
561        if entry_key(&entry.key) == Some("max_attempts") {
562            if let Node::IntLiteral(value) = entry.value.node {
563                if value >= 1 {
564                    return Some(PersonaStepRetry {
565                        max_attempts: value as u64,
566                    });
567                }
568            }
569        }
570    }
571    None
572}
573
574fn attr_step_budget(attr: &Attribute) -> Option<PersonaStepBudget> {
575    let budget = attr.named_arg("budget")?;
576    let Node::DictLiteral(entries) = &budget.node else {
577        return None;
578    };
579    let mut out = PersonaStepBudget::default();
580    let mut any = false;
581    for entry in entries {
582        match entry_key(&entry.key) {
583            Some("max_tokens") => {
584                if let Node::IntLiteral(value) = entry.value.node {
585                    if value >= 1 {
586                        out.max_tokens = Some(value as u64);
587                        any = true;
588                    }
589                }
590            }
591            Some("max_usd") => match entry.value.node {
592                Node::FloatLiteral(value) if value.is_finite() && value >= 0.0 => {
593                    out.max_usd = Some(value);
594                    any = true;
595                }
596                Node::IntLiteral(value) if value >= 0 => {
597                    out.max_usd = Some(value as f64);
598                    any = true;
599                }
600                _ => {}
601            },
602            _ => {}
603        }
604    }
605    any.then_some(out)
606}
607
608fn entry_key(node: &SNode) -> Option<&str> {
609    match &node.node {
610        Node::Identifier(value) | Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
611            Some(value.as_str())
612        }
613        _ => None,
614    }
615}
616
617fn collect_called_functions(body: &[SNode]) -> Vec<String> {
618    let mut calls = Vec::new();
619    for node in body {
620        collect_called_functions_node(node, &mut calls);
621    }
622    calls
623}
624
625fn collect_called_functions_node(node: &SNode, calls: &mut Vec<String>) {
626    match &node.node {
627        Node::FunctionCall { name, args, .. } => {
628            calls.push(name.clone());
629            collect_many(args, calls);
630        }
631        Node::LetBinding { value, .. }
632        | Node::VarBinding { value, .. }
633        | Node::ReturnStmt { value: Some(value) }
634        | Node::YieldExpr { value: Some(value) }
635        | Node::EmitExpr { value }
636        | Node::ThrowStmt { value }
637        | Node::Spread(value)
638        | Node::TryOperator { operand: value }
639        | Node::TryStar { operand: value }
640        | Node::UnaryOp { operand: value, .. } => collect_called_functions_node(value, calls),
641        Node::IfElse {
642            condition,
643            then_body,
644            else_body,
645        } => {
646            collect_called_functions_node(condition, calls);
647            collect_many(then_body, calls);
648            if let Some(else_body) = else_body {
649                collect_many(else_body, calls);
650            }
651        }
652        Node::ForIn { iterable, body, .. } => {
653            collect_called_functions_node(iterable, calls);
654            collect_many(body, calls);
655        }
656        Node::MatchExpr { value, arms } => {
657            collect_called_functions_node(value, calls);
658            for arm in arms {
659                collect_called_functions_node(&arm.pattern, calls);
660                if let Some(guard) = &arm.guard {
661                    collect_called_functions_node(guard, calls);
662                }
663                collect_many(&arm.body, calls);
664            }
665        }
666        Node::WhileLoop { condition, body } => {
667            collect_called_functions_node(condition, calls);
668            collect_many(body, calls);
669        }
670        Node::Retry { count, body } => {
671            collect_called_functions_node(count, calls);
672            collect_many(body, calls);
673        }
674        Node::CostRoute { options, body } => {
675            for (_, value) in options {
676                collect_called_functions_node(value, calls);
677            }
678            collect_many(body, calls);
679        }
680        Node::TryCatch {
681            has_catch: _,
682            body,
683            catch_body,
684            finally_body,
685            ..
686        } => {
687            collect_many(body, calls);
688            collect_many(catch_body, calls);
689            if let Some(finally_body) = finally_body {
690                collect_many(finally_body, calls);
691            }
692        }
693        Node::TryExpr { body }
694        | Node::SpawnExpr { body }
695        | Node::DeferStmt { body }
696        | Node::MutexBlock { body, .. }
697        | Node::Block(body)
698        | Node::Closure { body, .. } => collect_many(body, calls),
699        Node::DeadlineBlock { duration, body } => {
700            collect_called_functions_node(duration, calls);
701            collect_many(body, calls);
702        }
703        Node::GuardStmt {
704            condition,
705            else_body,
706        } => {
707            collect_called_functions_node(condition, calls);
708            collect_many(else_body, calls);
709        }
710        Node::RequireStmt { condition, message } => {
711            collect_called_functions_node(condition, calls);
712            if let Some(message) = message {
713                collect_called_functions_node(message, calls);
714            }
715        }
716        Node::Parallel {
717            expr,
718            body,
719            options,
720            ..
721        } => {
722            collect_called_functions_node(expr, calls);
723            for (_, value) in options {
724                collect_called_functions_node(value, calls);
725            }
726            collect_many(body, calls);
727        }
728        Node::SelectExpr {
729            cases,
730            timeout,
731            default_body,
732        } => {
733            for case in cases {
734                collect_called_functions_node(&case.channel, calls);
735                collect_many(&case.body, calls);
736            }
737            if let Some((duration, body)) = timeout {
738                collect_called_functions_node(duration, calls);
739                collect_many(body, calls);
740            }
741            if let Some(body) = default_body {
742                collect_many(body, calls);
743            }
744        }
745        Node::MethodCall { object, args, .. } | Node::OptionalMethodCall { object, args, .. } => {
746            collect_called_functions_node(object, calls);
747            collect_many(args, calls);
748        }
749        Node::PropertyAccess { object, .. } | Node::OptionalPropertyAccess { object, .. } => {
750            collect_called_functions_node(object, calls);
751        }
752        Node::SubscriptAccess { object, index }
753        | Node::OptionalSubscriptAccess { object, index } => {
754            collect_called_functions_node(object, calls);
755            collect_called_functions_node(index, calls);
756        }
757        Node::SliceAccess { object, start, end } => {
758            collect_called_functions_node(object, calls);
759            if let Some(start) = start {
760                collect_called_functions_node(start, calls);
761            }
762            if let Some(end) = end {
763                collect_called_functions_node(end, calls);
764            }
765        }
766        Node::BinaryOp { left, right, .. } => {
767            collect_called_functions_node(left, calls);
768            collect_called_functions_node(right, calls);
769        }
770        Node::Ternary {
771            condition,
772            true_expr,
773            false_expr,
774        } => {
775            collect_called_functions_node(condition, calls);
776            collect_called_functions_node(true_expr, calls);
777            collect_called_functions_node(false_expr, calls);
778        }
779        Node::Assignment { target, value, .. } => {
780            collect_called_functions_node(target, calls);
781            collect_called_functions_node(value, calls);
782        }
783        Node::EnumConstruct { args, .. } => collect_many(args, calls),
784        Node::StructConstruct { fields, .. } | Node::DictLiteral(fields) => {
785            collect_dict_calls(fields, calls);
786        }
787        Node::ListLiteral(items) | Node::OrPattern(items) => collect_many(items, calls),
788        Node::HitlExpr { args, .. } => {
789            for arg in args {
790                collect_called_functions_node(&arg.value, calls);
791            }
792        }
793        Node::AttributedDecl { inner, .. } => collect_called_functions_node(inner, calls),
794        Node::Pipeline { body, .. }
795        | Node::OverrideDecl { body, .. }
796        | Node::FnDecl { body, .. }
797        | Node::ToolDecl { body, .. } => collect_many(body, calls),
798        Node::SkillDecl { fields, .. } | Node::EvalPackDecl { fields, .. } => {
799            for (_, value) in fields {
800                collect_called_functions_node(value, calls);
801            }
802        }
803        _ => {}
804    }
805}
806
807fn collect_many(nodes: &[SNode], calls: &mut Vec<String>) {
808    for node in nodes {
809        collect_called_functions_node(node, calls);
810    }
811}
812
813fn collect_dict_calls(entries: &[DictEntry], calls: &mut Vec<String>) {
814    for entry in entries {
815        collect_called_functions_node(&entry.key, calls);
816        collect_called_functions_node(&entry.value, calls);
817    }
818}
819
820pub fn validate_persona_manifests(
821    manifest_path: &Path,
822    personas: &[PersonaManifestEntry],
823    context: &PersonaValidationContext,
824) -> Result<(), Vec<PersonaValidationError>> {
825    let mut errors = Vec::new();
826    for (index, persona) in personas.iter().enumerate() {
827        validate_persona(persona, index, manifest_path, context, &mut errors);
828    }
829    if errors.is_empty() {
830        Ok(())
831    } else {
832        Err(errors)
833    }
834}
835
836pub fn validate_persona(
837    persona: &PersonaManifestEntry,
838    index: usize,
839    manifest_path: &Path,
840    context: &PersonaValidationContext,
841    errors: &mut Vec<PersonaValidationError>,
842) {
843    let root = format!("[[personas]][{index}]");
844    for field in persona.extra.keys() {
845        persona_error(
846            manifest_path,
847            format!("{root}.{field}"),
848            "unknown persona field",
849            errors,
850        );
851    }
852    let name = validate_required_string(
853        manifest_path,
854        &root,
855        "name",
856        persona.name.as_deref(),
857        errors,
858    );
859    if let Some(name) = name {
860        validate_tokenish(manifest_path, &root, "name", name, errors);
861    }
862    validate_required_string(
863        manifest_path,
864        &root,
865        "description",
866        persona.description.as_deref(),
867        errors,
868    );
869    validate_required_string(
870        manifest_path,
871        &root,
872        "entry_workflow",
873        persona.entry_workflow.as_deref(),
874        errors,
875    );
876    if persona.tools.is_empty() && persona.capabilities.is_empty() {
877        persona_error(
878            manifest_path,
879            format!("{root}.tools"),
880            "persona requires at least one tool or capability",
881            errors,
882        );
883    }
884    if persona.autonomy_tier.is_none() {
885        persona_error(
886            manifest_path,
887            format!("{root}.autonomy_tier"),
888            "missing required autonomy tier",
889            errors,
890        );
891    }
892    if persona.receipt_policy.is_none() {
893        persona_error(
894            manifest_path,
895            format!("{root}.receipt_policy"),
896            "missing required receipt policy",
897            errors,
898        );
899    }
900    validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
901    for tool in &persona.tools {
902        if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
903            persona_error(
904                manifest_path,
905                format!("{root}.tools"),
906                format!("unknown tool '{tool}'"),
907                errors,
908            );
909        }
910    }
911    for capability in &persona.capabilities {
912        let Some((cap, op)) = capability.split_once('.') else {
913            persona_error(
914                manifest_path,
915                format!("{root}.capabilities"),
916                format!("capability '{capability}' must use capability.operation syntax"),
917                errors,
918            );
919            continue;
920        };
921        if cap.trim().is_empty() || op.trim().is_empty() {
922            persona_error(
923                manifest_path,
924                format!("{root}.capabilities"),
925                format!("capability '{capability}' must use capability.operation syntax"),
926                errors,
927            );
928        } else if !context.known_capabilities.is_empty()
929            && !context.known_capabilities.contains(capability)
930        {
931            persona_error(
932                manifest_path,
933                format!("{root}.capabilities"),
934                format!("unknown capability '{capability}'"),
935                errors,
936            );
937        }
938    }
939    validate_string_list(
940        manifest_path,
941        &root,
942        "context_packs",
943        &persona.context_packs,
944        errors,
945    );
946    validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
947    for schedule in &persona.schedules {
948        if schedule.trim().is_empty() {
949            persona_error(
950                manifest_path,
951                format!("{root}.schedules"),
952                "schedule entries must not be empty",
953                errors,
954            );
955        } else if let Err(error) = croner::Cron::from_str(schedule) {
956            persona_error(
957                manifest_path,
958                format!("{root}.schedules"),
959                format!("invalid cron schedule '{schedule}': {error}"),
960                errors,
961            );
962        }
963    }
964    for trigger in &persona.triggers {
965        match trigger.split_once('.') {
966            Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
967            _ => persona_error(
968                manifest_path,
969                format!("{root}.triggers"),
970                format!("trigger '{trigger}' must use provider.event syntax"),
971                errors,
972            ),
973        }
974    }
975    for handoff in &persona.handoffs {
976        if !context.known_names.contains(handoff) {
977            persona_error(
978                manifest_path,
979                format!("{root}.handoffs"),
980                format!("unknown handoff target '{handoff}'"),
981                errors,
982            );
983        }
984    }
985    validate_persona_budget(manifest_path, &root, &persona.budget, errors);
986    validate_persona_stages(manifest_path, &root, persona, context, errors);
987    validate_persona_nested_extra(
988        manifest_path,
989        &root,
990        "model_policy",
991        &persona.model_policy.extra,
992        errors,
993    );
994    validate_persona_nested_extra(
995        manifest_path,
996        &root,
997        "package_source",
998        &persona.package_source.extra,
999        errors,
1000    );
1001    validate_persona_nested_extra(
1002        manifest_path,
1003        &root,
1004        "rollout_policy",
1005        &persona.rollout_policy.extra,
1006        errors,
1007    );
1008    if let Some(percentage) = persona.rollout_policy.percentage {
1009        if percentage > 100 {
1010            persona_error(
1011                manifest_path,
1012                format!("{root}.rollout_policy.percentage"),
1013                "rollout percentage must be between 0 and 100",
1014                errors,
1015            );
1016        }
1017    }
1018}
1019
1020pub fn validate_required_string<'a>(
1021    manifest_path: &Path,
1022    root: &str,
1023    field: &str,
1024    value: Option<&'a str>,
1025    errors: &mut Vec<PersonaValidationError>,
1026) -> Option<&'a str> {
1027    match value.map(str::trim) {
1028        Some(value) if !value.is_empty() => Some(value),
1029        _ => {
1030            persona_error(
1031                manifest_path,
1032                format!("{root}.{field}"),
1033                format!("missing required {field}"),
1034                errors,
1035            );
1036            None
1037        }
1038    }
1039}
1040
1041pub fn validate_string_list(
1042    manifest_path: &Path,
1043    root: &str,
1044    field: &str,
1045    values: &[String],
1046    errors: &mut Vec<PersonaValidationError>,
1047) {
1048    for value in values {
1049        if value.trim().is_empty() {
1050            persona_error(
1051                manifest_path,
1052                format!("{root}.{field}"),
1053                format!("{field} entries must not be empty"),
1054                errors,
1055            );
1056        } else {
1057            validate_tokenish(manifest_path, root, field, value, errors);
1058        }
1059    }
1060}
1061
1062pub fn validate_tokenish(
1063    manifest_path: &Path,
1064    root: &str,
1065    field: &str,
1066    value: &str,
1067    errors: &mut Vec<PersonaValidationError>,
1068) {
1069    if !value
1070        .chars()
1071        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
1072    {
1073        persona_error(
1074            manifest_path,
1075            format!("{root}.{field}"),
1076            format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
1077            errors,
1078        );
1079    }
1080}
1081
1082pub fn validate_persona_budget(
1083    manifest_path: &Path,
1084    root: &str,
1085    budget: &PersonaBudget,
1086    errors: &mut Vec<PersonaValidationError>,
1087) {
1088    validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
1089    for (field, value) in [
1090        ("daily_usd", budget.daily_usd),
1091        ("hourly_usd", budget.hourly_usd),
1092        ("run_usd", budget.run_usd),
1093    ] {
1094        if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
1095            persona_error(
1096                manifest_path,
1097                format!("{root}.budget.{field}"),
1098                "budget amounts must be finite non-negative numbers",
1099                errors,
1100            );
1101        }
1102    }
1103}
1104
1105pub fn validate_persona_nested_extra(
1106    manifest_path: &Path,
1107    root: &str,
1108    field: &str,
1109    extra: &BTreeMap<String, toml::Value>,
1110    errors: &mut Vec<PersonaValidationError>,
1111) {
1112    for key in extra.keys() {
1113        persona_error(
1114            manifest_path,
1115            format!("{root}.{field}.{key}"),
1116            format!("unknown {field} field"),
1117            errors,
1118        );
1119    }
1120}
1121
1122pub fn validate_persona_stages(
1123    manifest_path: &Path,
1124    root: &str,
1125    persona: &PersonaManifestEntry,
1126    context: &PersonaValidationContext,
1127    errors: &mut Vec<PersonaValidationError>,
1128) {
1129    let stage_names: BTreeSet<&str> = persona
1130        .stages
1131        .iter()
1132        .map(|stage| stage.name.as_str())
1133        .collect();
1134    let mut seen = BTreeSet::new();
1135    for (index, stage) in persona.stages.iter().enumerate() {
1136        let field = format!("{root}.stages[{index}]");
1137        if stage.name.trim().is_empty() {
1138            persona_error(
1139                manifest_path,
1140                format!("{field}.name"),
1141                "stage name must not be empty",
1142                errors,
1143            );
1144        } else {
1145            validate_tokenish(manifest_path, &field, "name", &stage.name, errors);
1146            if !seen.insert(stage.name.as_str()) {
1147                persona_error(
1148                    manifest_path,
1149                    format!("{field}.name"),
1150                    format!("duplicate stage name '{}'", stage.name),
1151                    errors,
1152                );
1153            }
1154        }
1155        for key in stage.extra.keys() {
1156            persona_error(
1157                manifest_path,
1158                format!("{field}.{key}"),
1159                "unknown stage field",
1160                errors,
1161            );
1162        }
1163        if let Some(tools) = stage.allowed_tools.as_ref() {
1164            for tool in tools {
1165                if tool.trim().is_empty() {
1166                    persona_error(
1167                        manifest_path,
1168                        format!("{field}.allowed_tools"),
1169                        "allowed_tools entries must not be empty",
1170                        errors,
1171                    );
1172                    continue;
1173                }
1174                if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
1175                    persona_error(
1176                        manifest_path,
1177                        format!("{field}.allowed_tools"),
1178                        format!("unknown tool '{tool}'"),
1179                        errors,
1180                    );
1181                } else if !persona.tools.is_empty() && !persona.tools.contains(tool) {
1182                    persona_error(
1183                        manifest_path,
1184                        format!("{field}.allowed_tools"),
1185                        format!("tool '{tool}' is not part of the persona-level tools allowlist"),
1186                        errors,
1187                    );
1188                }
1189            }
1190        }
1191        if let Some(level) = stage.side_effect_level.as_deref() {
1192            match level {
1193                "none" | "read_only" | "workspace_write" | "process_exec" | "network" => {}
1194                _ => persona_error(
1195                    manifest_path,
1196                    format!("{field}.side_effect_level"),
1197                    format!(
1198                        "unknown side_effect_level '{level}' (expected none, read_only, workspace_write, process_exec, or network)"
1199                    ),
1200                    errors,
1201                ),
1202            }
1203        }
1204        if let Some(exit) = stage.on_exit.as_ref() {
1205            validate_persona_nested_extra(manifest_path, &field, "on_exit", &exit.extra, errors);
1206            for (key, target) in [
1207                ("on_complete", exit.on_complete.as_deref()),
1208                ("on_failure", exit.on_failure.as_deref()),
1209            ] {
1210                let Some(target) = target else { continue };
1211                if !stage_names.contains(target) {
1212                    persona_error(
1213                        manifest_path,
1214                        format!("{field}.on_exit.{key}"),
1215                        format!("unknown stage '{target}'"),
1216                        errors,
1217                    );
1218                }
1219            }
1220        }
1221    }
1222}
1223
1224pub fn persona_error(
1225    manifest_path: &Path,
1226    field_path: String,
1227    message: impl Into<String>,
1228    errors: &mut Vec<PersonaValidationError>,
1229) {
1230    errors.push(PersonaValidationError {
1231        manifest_path: manifest_path.to_path_buf(),
1232        field_path,
1233        message: message.into(),
1234    });
1235}
1236
1237pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
1238    BTreeMap::from([
1239        (
1240            "workspace",
1241            vec![
1242                "read_text",
1243                "write_text",
1244                "apply_edit",
1245                "delete",
1246                "exists",
1247                "file_exists",
1248                "list",
1249                "project_root",
1250                "roots",
1251            ],
1252        ),
1253        ("process", vec!["exec"]),
1254        ("template", vec!["render"]),
1255        ("interaction", vec!["ask"]),
1256        (
1257            "runtime",
1258            vec![
1259                "approved_plan",
1260                "dry_run",
1261                "pipeline_input",
1262                "record_run",
1263                "set_result",
1264                "task",
1265            ],
1266        ),
1267        (
1268            "project",
1269            vec![
1270                "agent_instructions",
1271                "code_patterns",
1272                "compute_content_hash",
1273                "ide_context",
1274                "lessons",
1275                "mcp_config",
1276                "metadata_get",
1277                "metadata_inspect",
1278                "metadata_refresh_hashes",
1279                "metadata_save",
1280                "metadata_set",
1281                "metadata_stale",
1282                "path_metadata_entries",
1283                "path_metadata_get",
1284                "path_metadata_set",
1285                "scan",
1286                "scope_test_command",
1287                "test_commands",
1288            ],
1289        ),
1290        (
1291            "session",
1292            vec![
1293                "active_roots",
1294                "changed_paths",
1295                "preread_get",
1296                "preread_read_many",
1297            ],
1298        ),
1299        (
1300            "editor",
1301            vec!["get_active_file", "get_selection", "get_visible_files"],
1302        ),
1303        ("diagnostics", vec!["get_causal_traces", "get_errors"]),
1304        ("git", vec!["get_branch", "get_diff"]),
1305        ("learning", vec!["get_learned_rules", "report_correction"]),
1306    ])
1307}
1308
1309pub fn default_persona_capabilities() -> BTreeSet<String> {
1310    let mut capabilities = BTreeSet::new();
1311    for (capability, operations) in default_persona_capability_map() {
1312        for operation in operations {
1313            capabilities.insert(format!("{capability}.{operation}"));
1314        }
1315    }
1316    capabilities
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use super::*;
1322
1323    fn context(names: &[&str]) -> PersonaValidationContext {
1324        PersonaValidationContext {
1325            known_capabilities: default_persona_capabilities(),
1326            known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
1327            known_names: names.iter().map(|name| name.to_string()).collect(),
1328        }
1329    }
1330
1331    #[test]
1332    fn validates_sample_manifest() {
1333        let parsed = parse_persona_manifest_str(
1334            r#"
1335[[personas]]
1336name = "merge_captain"
1337description = "Owns PR readiness."
1338entry_workflow = "workflows/merge_captain.harn#run"
1339tools = ["github", "ci"]
1340capabilities = ["git.get_diff"]
1341autonomy = "act_with_approval"
1342receipts = "required"
1343triggers = ["github.pr_opened"]
1344schedules = ["*/30 * * * *"]
1345handoffs = ["review_captain"]
1346context_packs = ["repo_policy"]
1347evals = ["merge_safety"]
1348budget = { daily_usd = 20.0 }
1349
1350[[personas]]
1351name = "review_captain"
1352description = "Reviews code."
1353entry_workflow = "workflows/review_captain.harn#run"
1354tools = ["github"]
1355autonomy_tier = "suggest"
1356receipt_policy = "optional"
1357"#,
1358        )
1359        .expect("manifest parses");
1360
1361        validate_persona_manifests(
1362            Path::new("harn.toml"),
1363            &parsed.personas,
1364            &context(&["merge_captain", "review_captain"]),
1365        )
1366        .expect("manifest validates");
1367    }
1368
1369    #[test]
1370    fn parses_output_style_as_string_and_table() {
1371        let parsed = parse_persona_manifest_str(
1372            r#"
1373[[personas]]
1374name = "concise_bot"
1375description = "Terse."
1376entry_workflow = "workflows/x.harn#run"
1377tools = ["github"]
1378autonomy = "suggest"
1379receipts = "optional"
1380output_style = "concise"
1381
1382[[personas]]
1383name = "styled_bot"
1384description = "Styled."
1385entry_workflow = "workflows/y.harn#run"
1386tools = ["github"]
1387autonomy = "suggest"
1388receipts = "optional"
1389output_style = { name = "friendly", instructions = "Use warm, plain language." }
1390"#,
1391        )
1392        .expect("manifest parses");
1393
1394        let first = parsed.personas[0].output_style.as_ref().expect("style set");
1395        assert_eq!(first.name.as_deref(), Some("concise"));
1396        assert_eq!(first.instructions, None);
1397
1398        let second = parsed.personas[1].output_style.as_ref().expect("style set");
1399        assert_eq!(second.name.as_deref(), Some("friendly"));
1400        assert_eq!(
1401            second.instructions.as_deref(),
1402            Some("Use warm, plain language.")
1403        );
1404
1405        // A persona without the field leaves it None and validates fine.
1406        validate_persona_manifests(
1407            Path::new("harn.toml"),
1408            &parsed.personas,
1409            &context(&["concise_bot", "styled_bot"]),
1410        )
1411        .expect("manifest validates");
1412    }
1413
1414    #[test]
1415    fn bad_manifest_produces_typed_errors() {
1416        let parsed = parse_persona_manifest_str(
1417            r#"
1418[[personas]]
1419name = "bad"
1420description = ""
1421entry_workflow = ""
1422tools = ["unknown"]
1423capabilities = ["git"]
1424autonomy = "shadow"
1425receipts = "required"
1426triggers = ["github"]
1427schedules = [""]
1428handoffs = ["missing"]
1429budget = { daily_usd = -1.0, surprise = true }
1430surprise = true
1431"#,
1432        )
1433        .expect("manifest parses");
1434
1435        let errors = validate_persona_manifests(
1436            Path::new("harn.toml"),
1437            &parsed.personas,
1438            &context(&["bad"]),
1439        )
1440        .expect_err("manifest rejects");
1441        let fields: BTreeSet<_> = errors
1442            .iter()
1443            .map(|error| error.field_path.as_str())
1444            .collect();
1445        assert!(fields.contains("[[personas]][0].description"));
1446        assert!(fields.contains("[[personas]][0].entry_workflow"));
1447        assert!(fields.contains("[[personas]][0].tools"));
1448        assert!(fields.contains("[[personas]][0].capabilities"));
1449        assert!(fields.contains("[[personas]][0].triggers"));
1450        assert!(fields.contains("[[personas]][0].schedules"));
1451        assert!(fields.contains("[[personas]][0].handoffs"));
1452        assert!(fields.contains("[[personas]][0].budget.daily_usd"));
1453        assert!(fields.contains("[[personas]][0].budget.surprise"));
1454        assert!(fields.contains("[[personas]][0].surprise"));
1455    }
1456
1457    #[test]
1458    fn manifest_stages_round_trip_through_serde() {
1459        let parsed = parse_persona_manifest_str(
1460            r#"
1461[[personas]]
1462name = "scoped"
1463description = "Per-stage scoping demo."
1464entry_workflow = "workflows/scoped.harn#run"
1465tools = ["github", "ci"]
1466autonomy = "act_with_approval"
1467receipts = "required"
1468
1469[[personas.stages]]
1470name = "research"
1471allowed_tools = ["github"]
1472side_effect_level = "read_only"
1473
1474[[personas.stages]]
1475name = "act"
1476allowed_tools = ["github", "ci"]
1477side_effect_level = "process_exec"
1478max_iterations = 4
1479on_exit = { on_complete = "research" }
1480"#,
1481        )
1482        .expect("manifest parses");
1483
1484        validate_persona_manifests(
1485            Path::new("harn.toml"),
1486            &parsed.personas,
1487            &context(&["scoped"]),
1488        )
1489        .expect("stage-scoped manifest validates");
1490        let persona = &parsed.personas[0];
1491        assert_eq!(persona.stages.len(), 2);
1492        assert_eq!(persona.stages[0].name, "research");
1493        assert_eq!(
1494            persona.stages[0].allowed_tools.as_deref(),
1495            Some(["github".to_string()].as_slice())
1496        );
1497        assert_eq!(
1498            persona.stages[1]
1499                .on_exit
1500                .as_ref()
1501                .unwrap()
1502                .on_complete
1503                .as_deref(),
1504            Some("research")
1505        );
1506
1507        // Round-trip via the TOML serializer to ensure the shape is stable.
1508        let serialised = toml::to_string(&PersonaManifestDocument {
1509            personas: parsed.personas.clone(),
1510        })
1511        .expect("serialize");
1512        let reparsed = parse_persona_manifest_str(&serialised).expect("reparse");
1513        assert_eq!(reparsed.personas, parsed.personas);
1514    }
1515
1516    #[test]
1517    fn stage_validation_flags_unknown_targets_and_levels() {
1518        let parsed = parse_persona_manifest_str(
1519            r#"
1520[[personas]]
1521name = "scoped"
1522description = "Bad stages."
1523entry_workflow = "workflows/scoped.harn#run"
1524tools = ["github"]
1525autonomy = "suggest"
1526receipts = "optional"
1527
1528[[personas.stages]]
1529name = "research"
1530allowed_tools = ["ci"]
1531side_effect_level = "do_anything"
1532on_exit = { on_complete = "missing" }
1533
1534[[personas.stages]]
1535name = "research"
1536"#,
1537        )
1538        .expect("manifest parses");
1539
1540        let errors = validate_persona_manifests(
1541            Path::new("harn.toml"),
1542            &parsed.personas,
1543            &context(&["scoped"]),
1544        )
1545        .expect_err("rejects bad stage config");
1546        let fields: BTreeSet<_> = errors
1547            .iter()
1548            .map(|error| error.field_path.as_str())
1549            .collect();
1550        assert!(fields.contains("[[personas]][0].stages[0].allowed_tools"));
1551        assert!(fields.contains("[[personas]][0].stages[0].side_effect_level"));
1552        assert!(fields.contains("[[personas]][0].stages[0].on_exit.on_complete"));
1553        assert!(fields.contains("[[personas]][0].stages[1].name"));
1554    }
1555
1556    #[test]
1557    fn source_persona_picks_up_stage_attributes() {
1558        let parsed = parse_persona_source_str(
1559            r#"
1560@persona(name: "scoped", tools: [github, ci], stages: [
1561  {name: "research", allowed_tools: [github]},
1562  {name: "act", allowed_tools: [github, ci], side_effect_level: "process_exec"},
1563])
1564fn scoped(ctx) {
1565  research(ctx)
1566  act(ctx)
1567}
1568
1569@step(name: "research") fn research(ctx) { return ctx }
1570@step(name: "act") fn act(ctx) { return ctx }
1571"#,
1572        )
1573        .expect("source persona parses");
1574
1575        let persona = &parsed.personas[0];
1576        assert_eq!(persona.stages.len(), 2);
1577        assert_eq!(persona.stages[0].name, "research");
1578        assert_eq!(
1579            persona.stages[0].allowed_tools.as_deref(),
1580            Some(["github".to_string()].as_slice()),
1581        );
1582        assert_eq!(
1583            persona.stages[1].side_effect_level.as_deref(),
1584            Some("process_exec"),
1585        );
1586    }
1587
1588    #[test]
1589    fn source_persona_extracts_called_steps_in_order() {
1590        let parsed = parse_persona_source_str(
1591            r#"
1592@persona(name: "merge_captain")
1593fn merge_captain(ctx) {
1594  plan(ctx)
1595  verify(ctx)
1596}
1597
1598@step(name: "plan", model: "gpt-5.4-mini", retry: {max_attempts: 2})
1599fn plan(ctx) {
1600  return ctx
1601}
1602
1603@step(name: "verify", error_boundary: continue)
1604fn verify(ctx) {
1605  return ctx
1606}
1607"#,
1608        )
1609        .expect("source persona parses");
1610        assert_eq!(parsed.personas.len(), 1);
1611        let persona = &parsed.personas[0];
1612        assert_eq!(persona.name.as_deref(), Some("merge_captain"));
1613        assert_eq!(persona.steps.len(), 2);
1614        assert_eq!(persona.steps[0].name, "plan");
1615        assert_eq!(persona.steps[0].model.as_deref(), Some("gpt-5.4-mini"));
1616        assert_eq!(persona.steps[0].retry.as_ref().unwrap().max_attempts, 2);
1617        assert_eq!(persona.steps[1].error_boundary.as_deref(), Some("continue"));
1618    }
1619}