Skip to main content

harn_cli/commands/
persona.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process;
4use std::sync::Arc;
5
6use harn_vm::event_log::{AnyEventLog, EventLog};
7
8use crate::cli::{
9    PersonaCheckArgs, PersonaControlArgs, PersonaInspectArgs, PersonaListArgs, PersonaSpendArgs,
10    PersonaStatusArgs, PersonaTickArgs, PersonaTriggerArgs,
11};
12use crate::package::{self, PersonaManifestEntry, PersonaValidationError, ResolvedPersonaManifest};
13
14/// In-process variant of `harn persona list --json` used by the binary's
15/// dispatcher and by integration tests that want to assert on the
16/// structured payload without spawning a subprocess.
17pub fn list_payload(manifest: Option<&Path>) -> Result<Vec<serde_json::Value>, String> {
18    let catalog = load_catalog_result(manifest)?;
19    Ok(catalog
20        .personas
21        .iter()
22        .map(|persona| persona_to_json(persona, &catalog))
23        .collect())
24}
25
26pub(crate) fn run_list(manifest: Option<&Path>, args: &PersonaListArgs) {
27    if args.json {
28        let personas = list_payload(manifest).unwrap_or_else(|error| fatal(&error));
29        println!(
30            "{}",
31            serde_json::to_string_pretty(&personas)
32                .unwrap_or_else(|error| fatal(&format!("failed to serialize personas: {error}")))
33        );
34        return;
35    }
36
37    let catalog = load_catalog_or_exit(manifest);
38    if catalog.personas.is_empty() {
39        println!(
40            "No personas declared in {}.",
41            catalog.manifest_path.display()
42        );
43        return;
44    }
45
46    println!("Personas in {}:", catalog.manifest_path.display());
47    let name_width = catalog
48        .personas
49        .iter()
50        .filter_map(|persona| persona.name.as_ref())
51        .map(String::len)
52        .max()
53        .unwrap_or(4);
54    for persona in &catalog.personas {
55        let name = persona.name.as_deref().unwrap_or("<unnamed>");
56        let tier = persona
57            .autonomy_tier
58            .map(|tier| tier.as_str())
59            .unwrap_or("<missing>");
60        let receipts = persona
61            .receipt_policy
62            .map(|policy| policy.as_str())
63            .unwrap_or("<missing>");
64        let entry = persona.entry_workflow.as_deref().unwrap_or("<missing>");
65        println!("  {name:<name_width$}  tier={tier:<17} receipts={receipts:<8} entry={entry}");
66    }
67}
68
69/// In-process variant of `harn persona check --json`. Returns the JSON
70/// payload the CLI would print on success; structured validation errors
71/// surface in `Err` so callers can format or assert on them.
72pub fn check_payload(
73    path: Option<&Path>,
74) -> Result<serde_json::Value, Vec<PersonaValidationError>> {
75    let catalog = load_catalog_validation(path)?;
76    Ok(serde_json::json!({
77        "ok": true,
78        "manifest_path": catalog.manifest_path,
79        "personas": catalog.personas.iter().map(|persona| {
80            serde_json::json!({
81                "name": persona.name.as_deref().unwrap_or_default(),
82                "triggers": &persona.triggers,
83                "tools": &persona.tools,
84                "autonomy": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
85                "receipts": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
86            })
87        }).collect::<Vec<_>>(),
88    }))
89}
90
91pub(crate) fn run_check(manifest: Option<&Path>, args: &PersonaCheckArgs) {
92    let selected = args.path.as_deref().or(manifest);
93    if args.json {
94        match check_payload(selected) {
95            Ok(payload) => println!(
96                "{}",
97                serde_json::to_string_pretty(&payload).unwrap_or_else(|error| fatal(&format!(
98                    "failed to serialize persona check output: {error}"
99                )))
100            ),
101            Err(errors) => {
102                print_validation_errors_json(&errors);
103                process::exit(1);
104            }
105        }
106        return;
107    }
108    let catalog = match load_catalog_validation(selected) {
109        Ok(catalog) => catalog,
110        Err(errors) => fatal(
111            &errors
112                .iter()
113                .map(ToString::to_string)
114                .collect::<Vec<_>>()
115                .join("\n"),
116        ),
117    };
118    println!(
119        "ok: {} persona manifest validates ({} personas)",
120        catalog.manifest_path.display(),
121        catalog.personas.len()
122    );
123}
124
125/// In-process variant of `harn persona inspect <name> --json`.
126pub fn inspect_payload(manifest: Option<&Path>, name: &str) -> Result<serde_json::Value, String> {
127    let catalog = load_catalog_result(manifest)?;
128    let persona = catalog
129        .personas
130        .iter()
131        .find(|persona| persona.name.as_deref() == Some(name))
132        .ok_or_else(|| {
133            format!(
134                "persona '{}' not found in {}",
135                name,
136                catalog.manifest_path.display()
137            )
138        })?;
139    Ok(persona_to_json(persona, &catalog))
140}
141
142pub(crate) fn run_inspect(manifest: Option<&Path>, args: &PersonaInspectArgs) {
143    if args.json {
144        let json = inspect_payload(manifest, &args.name).unwrap_or_else(|error| fatal(&error));
145        println!(
146            "{}",
147            serde_json::to_string_pretty(&json)
148                .unwrap_or_else(|error| fatal(&format!("failed to serialize persona: {error}")))
149        );
150        return;
151    }
152
153    let catalog = load_catalog_or_exit(manifest);
154    let Some(persona) = catalog
155        .personas
156        .iter()
157        .find(|persona| persona.name.as_deref() == Some(args.name.as_str()))
158    else {
159        fatal(&format!(
160            "persona '{}' not found in {}",
161            args.name,
162            catalog.manifest_path.display()
163        ));
164    };
165
166    println!(
167        "name:           {}",
168        persona.name.as_deref().unwrap_or_default()
169    );
170    if let Some(version) = &persona.version {
171        println!("version:        {version}");
172    }
173    println!(
174        "description:    {}",
175        persona.description.as_deref().unwrap_or_default()
176    );
177    println!(
178        "entry_workflow: {}",
179        persona.entry_workflow.as_deref().unwrap_or_default()
180    );
181    println!("tools:          {}", comma_or_dash(&persona.tools));
182    println!("capabilities:   {}", comma_or_dash(&persona.capabilities));
183    println!(
184        "autonomy_tier:  {}",
185        persona
186            .autonomy_tier
187            .map(|tier| tier.as_str())
188            .unwrap_or_default()
189    );
190    println!(
191        "receipt_policy: {}",
192        persona
193            .receipt_policy
194            .map(|policy| policy.as_str())
195            .unwrap_or_default()
196    );
197    println!("triggers:       {}", comma_or_dash(&persona.triggers));
198    println!("schedules:      {}", comma_or_dash(&persona.schedules));
199    println!("handoffs:       {}", comma_or_dash(&persona.handoffs));
200    println!("context_packs:  {}", comma_or_dash(&persona.context_packs));
201    println!("evals:          {}", comma_or_dash(&persona.evals));
202    if !persona.steps.is_empty() {
203        println!("steps:");
204        for step in &persona.steps {
205            let mut detail = format!("  - {} ({})", step.name, step.function);
206            if let Some(model) = step.model.as_deref() {
207                detail.push_str(&format!(" model={model}"));
208            }
209            if let Some(budget) = step.budget.as_ref() {
210                if let Some(max_tokens) = budget.max_tokens {
211                    detail.push_str(&format!(" max_tokens={max_tokens}"));
212                }
213                if let Some(max_usd) = budget.max_usd {
214                    detail.push_str(&format!(" max_usd={max_usd}"));
215                }
216            }
217            if let Some(boundary) = step.error_boundary.as_deref() {
218                detail.push_str(&format!(" error_boundary={boundary}"));
219            }
220            println!("{detail}");
221        }
222    }
223    if !persona.stages.is_empty() {
224        println!("stages:");
225        for stage in &persona.stages {
226            let tools = stage
227                .allowed_tools
228                .as_deref()
229                .map(comma_or_dash)
230                .unwrap_or_else(|| "inherit".to_string());
231            let mut detail = format!("  - {} tools={tools}", stage.name);
232            if let Some(level) = stage.side_effect_level.as_deref() {
233                detail.push_str(&format!(" side_effect={level}"));
234            }
235            if let Some(max) = stage.max_iterations {
236                detail.push_str(&format!(" max_iterations={max}"));
237            }
238            println!("{detail}");
239        }
240    }
241    if let Some(owner) = &persona.owner {
242        println!("owner:          {owner}");
243    }
244    println!("manifest:       {}", catalog.manifest_path.display());
245}
246
247/// In-process variant of `harn persona status`.
248pub async fn status_payload(
249    manifest: Option<&Path>,
250    state_dir: &Path,
251    name: &str,
252    at: Option<&str>,
253) -> Result<harn_vm::PersonaStatus, String> {
254    let catalog = load_catalog_result(manifest)?;
255    let binding = runtime_binding_or_err(&catalog, name)?;
256    let log = open_persona_log(state_dir)?;
257    let now_ms = timestamp_arg(at)?;
258    harn_vm::persona_status(&log, &binding, now_ms).await
259}
260
261pub(crate) async fn run_status(
262    manifest: Option<&Path>,
263    state_dir: &Path,
264    args: &PersonaStatusArgs,
265) -> Result<(), String> {
266    let status = status_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
267    print_status(&status, args.json);
268    Ok(())
269}
270
271/// In-process variant of `harn persona pause`.
272pub async fn pause_payload(
273    manifest: Option<&Path>,
274    state_dir: &Path,
275    name: &str,
276    at: Option<&str>,
277) -> Result<harn_vm::PersonaStatus, String> {
278    let catalog = load_catalog_result(manifest)?;
279    let binding = runtime_binding_or_err(&catalog, name)?;
280    let log = open_persona_log(state_dir)?;
281    let now_ms = timestamp_arg(at)?;
282    harn_vm::pause_persona(&log, &binding, now_ms).await
283}
284
285pub(crate) async fn run_pause(
286    manifest: Option<&Path>,
287    state_dir: &Path,
288    args: &PersonaControlArgs,
289) -> Result<(), String> {
290    let status = pause_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
291    print_status(&status, args.json);
292    Ok(())
293}
294
295/// In-process variant of `harn persona resume`.
296pub async fn resume_payload(
297    manifest: Option<&Path>,
298    state_dir: &Path,
299    name: &str,
300    at: Option<&str>,
301) -> Result<harn_vm::PersonaStatus, String> {
302    let catalog = load_catalog_result(manifest)?;
303    let binding = runtime_binding_or_err(&catalog, name)?;
304    let log = open_persona_log(state_dir)?;
305    let now_ms = timestamp_arg(at)?;
306    harn_vm::resume_persona(&log, &binding, now_ms).await
307}
308
309pub(crate) async fn run_resume(
310    manifest: Option<&Path>,
311    state_dir: &Path,
312    args: &PersonaControlArgs,
313) -> Result<(), String> {
314    let status = resume_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
315    print_status(&status, args.json);
316    Ok(())
317}
318
319/// In-process variant of `harn persona disable`.
320pub async fn disable_payload(
321    manifest: Option<&Path>,
322    state_dir: &Path,
323    name: &str,
324    at: Option<&str>,
325) -> Result<harn_vm::PersonaStatus, String> {
326    let catalog = load_catalog_result(manifest)?;
327    let binding = runtime_binding_or_err(&catalog, name)?;
328    let log = open_persona_log(state_dir)?;
329    let now_ms = timestamp_arg(at)?;
330    harn_vm::disable_persona(&log, &binding, now_ms).await
331}
332
333pub(crate) async fn run_disable(
334    manifest: Option<&Path>,
335    state_dir: &Path,
336    args: &PersonaControlArgs,
337) -> Result<(), String> {
338    let status = disable_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
339    print_status(&status, args.json);
340    Ok(())
341}
342
343/// In-process variant of `harn persona tick`. Returns the run receipt that
344/// the CLI would otherwise print to stdout.
345pub async fn tick_payload(
346    manifest: Option<&Path>,
347    state_dir: &Path,
348    name: &str,
349    at: Option<&str>,
350    cost_usd: f64,
351    tokens: u64,
352) -> Result<harn_vm::PersonaRunReceipt, String> {
353    let catalog = load_catalog_result(manifest)?;
354    let binding = runtime_binding_or_err(&catalog, name)?;
355    let log = open_persona_log(state_dir)?;
356    let now_ms = timestamp_arg(at)?;
357    let receipt = harn_vm::fire_persona_schedule(
358        &log,
359        &binding,
360        harn_vm::PersonaRunCost {
361            cost_usd,
362            tokens,
363            ..Default::default()
364        },
365        now_ms,
366    )
367    .await?;
368    log.flush().await.map_err(|error| error.to_string())?;
369    Ok(receipt)
370}
371
372pub(crate) async fn run_tick(
373    manifest: Option<&Path>,
374    state_dir: &Path,
375    args: &PersonaTickArgs,
376) -> Result<(), String> {
377    let receipt = tick_payload(
378        manifest,
379        state_dir,
380        &args.name,
381        args.at.as_deref(),
382        args.cost_usd,
383        args.tokens,
384    )
385    .await?;
386    print_receipt(&receipt, args.json);
387    Ok(())
388}
389
390/// In-process variant of `harn persona trigger`. `metadata_pairs` accepts
391/// the same `KEY=VALUE` strings the CLI does.
392#[allow(clippy::too_many_arguments)]
393pub async fn trigger_payload(
394    manifest: Option<&Path>,
395    state_dir: &Path,
396    name: &str,
397    provider: &str,
398    kind: &str,
399    metadata_pairs: &[String],
400    at: Option<&str>,
401    cost_usd: f64,
402    tokens: u64,
403) -> Result<harn_vm::PersonaRunReceipt, String> {
404    let catalog = load_catalog_result(manifest)?;
405    let binding = runtime_binding_or_err(&catalog, name)?;
406    let log = open_persona_log(state_dir)?;
407    let now_ms = timestamp_arg(at)?;
408    let metadata = parse_metadata(metadata_pairs)?;
409    let receipt = harn_vm::fire_persona_trigger(
410        &log,
411        &binding,
412        provider,
413        kind,
414        metadata,
415        harn_vm::PersonaRunCost {
416            cost_usd,
417            tokens,
418            ..Default::default()
419        },
420        now_ms,
421    )
422    .await?;
423    log.flush().await.map_err(|error| error.to_string())?;
424    Ok(receipt)
425}
426
427pub(crate) async fn run_trigger(
428    manifest: Option<&Path>,
429    state_dir: &Path,
430    args: &PersonaTriggerArgs,
431) -> Result<(), String> {
432    let receipt = trigger_payload(
433        manifest,
434        state_dir,
435        &args.name,
436        &args.provider,
437        &args.kind,
438        &args.metadata,
439        args.at.as_deref(),
440        args.cost_usd,
441        args.tokens,
442    )
443    .await?;
444    print_receipt(&receipt, args.json);
445    Ok(())
446}
447
448/// In-process variant of `harn persona spend`.
449pub async fn spend_payload(
450    manifest: Option<&Path>,
451    state_dir: &Path,
452    name: &str,
453    at: Option<&str>,
454    cost_usd: f64,
455    tokens: u64,
456) -> Result<harn_vm::PersonaBudgetStatus, String> {
457    let catalog = load_catalog_result(manifest)?;
458    let binding = runtime_binding_or_err(&catalog, name)?;
459    let log = open_persona_log(state_dir)?;
460    let now_ms = timestamp_arg(at)?;
461    let budget = harn_vm::record_persona_spend(
462        &log,
463        &binding,
464        harn_vm::PersonaRunCost {
465            cost_usd,
466            tokens,
467            ..Default::default()
468        },
469        now_ms,
470    )
471    .await?;
472    log.flush().await.map_err(|error| error.to_string())?;
473    Ok(budget)
474}
475
476pub(crate) async fn run_spend(
477    manifest: Option<&Path>,
478    state_dir: &Path,
479    args: &PersonaSpendArgs,
480) -> Result<(), String> {
481    let budget = spend_payload(
482        manifest,
483        state_dir,
484        &args.name,
485        args.at.as_deref(),
486        args.cost_usd,
487        args.tokens,
488    )
489    .await?;
490    if args.json {
491        println!(
492            "{}",
493            serde_json::to_string_pretty(&budget)
494                .unwrap_or_else(|error| fatal(&format!("failed to serialize budget: {error}")))
495        );
496    } else {
497        println!(
498            "budget: spent_today=${:.4} tokens_today={} exhausted={}",
499            budget.spent_today_usd, budget.tokens_today, budget.exhausted
500        );
501    }
502    Ok(())
503}
504
505fn load_catalog_or_exit(manifest: Option<&Path>) -> ResolvedPersonaManifest {
506    match load_catalog_result(manifest) {
507        Ok(catalog) => catalog,
508        Err(message) => fatal(&message),
509    }
510}
511
512fn load_catalog_result(manifest: Option<&Path>) -> Result<ResolvedPersonaManifest, String> {
513    load_catalog_validation(manifest).map_err(|errors| validation_errors_to_string(&errors))
514}
515
516fn load_catalog_validation(
517    manifest: Option<&Path>,
518) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
519    let result = if let Some(path) = manifest {
520        package::load_personas_from_manifest_path(path).map(Some)
521    } else {
522        package::load_personas_config(None)
523    };
524    match result {
525        Ok(Some(catalog)) => Ok(catalog),
526        Ok(None) => Err(vec![PersonaValidationError {
527            manifest_path: PathBuf::from("harn.toml"),
528            field_path: "harn.toml".to_string(),
529            message: "no harn.toml found; pass --manifest <path> or run inside a Harn project"
530                .to_string(),
531        }]),
532        Err(errors) => Err(errors),
533    }
534}
535
536fn validation_errors_to_string(errors: &[PersonaValidationError]) -> String {
537    errors
538        .iter()
539        .map(ToString::to_string)
540        .collect::<Vec<_>>()
541        .join("\n")
542}
543
544fn runtime_binding_or_err(
545    catalog: &ResolvedPersonaManifest,
546    name: &str,
547) -> Result<harn_vm::PersonaRuntimeBinding, String> {
548    let persona = catalog
549        .personas
550        .iter()
551        .find(|persona| persona.name.as_deref() == Some(name))
552        .ok_or_else(|| {
553            format!(
554                "persona '{}' not found in {}",
555                name,
556                catalog.manifest_path.display()
557            )
558        })?;
559    Ok(crate::package::persona_runtime_binding(
560        persona.name.as_deref().unwrap_or_default(),
561        persona,
562    ))
563}
564
565pub(super) fn open_persona_log(state_dir: &Path) -> Result<Arc<AnyEventLog>, String> {
566    let state_dir = absolutize_from_cwd(state_dir)?;
567    std::fs::create_dir_all(&state_dir).map_err(|error| {
568        format!(
569            "failed to create persona state dir {}: {error}",
570            state_dir.display()
571        )
572    })?;
573    harn_vm::event_log::install_default_for_base_dir(&state_dir)
574        .map_err(|error| format!("failed to open persona event log: {error}"))
575}
576
577fn absolutize_from_cwd(path: &Path) -> Result<PathBuf, String> {
578    if path.is_absolute() {
579        return Ok(path.to_path_buf());
580    }
581    std::env::current_dir()
582        .map(|cwd| cwd.join(path))
583        .map_err(|error| format!("failed to read current directory: {error}"))
584}
585
586pub(super) fn timestamp_arg(value: Option<&str>) -> Result<i64, String> {
587    match value {
588        Some(value) => harn_vm::parse_persona_ms(value),
589        None => Ok(harn_vm::persona_now_ms()),
590    }
591}
592
593fn parse_metadata(values: &[String]) -> Result<BTreeMap<String, String>, String> {
594    let mut metadata = BTreeMap::new();
595    for value in values {
596        let Some((key, raw)) = value.split_once('=') else {
597            return Err(format!("metadata '{value}' must use KEY=VALUE syntax"));
598        };
599        let key = key.trim();
600        if key.is_empty() {
601            return Err(format!("metadata '{value}' has an empty key"));
602        }
603        metadata.insert(key.to_string(), raw.to_string());
604    }
605    Ok(metadata)
606}
607
608fn print_status(status: &harn_vm::PersonaStatus, json: bool) {
609    if json {
610        println!(
611            "{}",
612            serde_json::to_string_pretty(status)
613                .unwrap_or_else(|error| fatal(&format!("failed to serialize status: {error}")))
614        );
615        return;
616    }
617    println!("persona:        {}", status.name);
618    println!(
619        "template_ref:   {}",
620        status.template_ref.as_deref().unwrap_or("-")
621    );
622    println!("state:          {}", status.state.as_str());
623    println!("entry_workflow: {}", status.entry_workflow);
624    println!("role:           {}", status.role);
625    println!(
626        "assignment:     {}",
627        status
628            .current_assignment
629            .as_ref()
630            .map(|assignment| assignment.work_key.as_str())
631            .unwrap_or("-")
632    );
633    println!(
634        "last_run:       {}",
635        status.last_run.as_deref().unwrap_or("-")
636    );
637    println!(
638        "next_run:       {}",
639        status.next_scheduled_run.as_deref().unwrap_or("-")
640    );
641    println!("queued_events:  {}", status.queued_events);
642    if !status.handoff_inbox.is_empty() {
643        println!("handoffs:");
644        for handoff in &status.handoff_inbox {
645            println!(
646                "  - {} kind={} from={} task={}",
647                handoff
648                    .handoff_id
649                    .as_deref()
650                    .unwrap_or(handoff.work_key.as_str()),
651                handoff.handoff_kind.as_deref().unwrap_or("-"),
652                handoff.source_persona.as_deref().unwrap_or("-"),
653                handoff.task.as_deref().unwrap_or("-")
654            );
655        }
656    }
657    println!(
658        "active_lease:   {}",
659        status
660            .active_lease
661            .as_ref()
662            .map(|lease| lease.id.as_str())
663            .unwrap_or("-")
664    );
665    println!(
666        "budget:         spent_today=${:.4} remaining_today={}",
667        status.budget.spent_today_usd,
668        status
669            .budget
670            .remaining_today_usd
671            .map(|value| format!("${value:.4}"))
672            .unwrap_or_else(|| "-".to_string())
673    );
674    if let Some(receipt) = status.value_receipts.last() {
675        println!(
676            "last_receipt:   {} paid=${:.4} avoided=${:.4}",
677            receipt.kind.as_str(),
678            receipt.paid_cost_usd,
679            receipt.avoided_cost_usd
680        );
681    }
682    if let Some(error) = &status.last_error {
683        println!("last_error:     {error}");
684    }
685}
686
687fn print_receipt(receipt: &harn_vm::PersonaRunReceipt, json: bool) {
688    if json {
689        println!(
690            "{}",
691            serde_json::to_string_pretty(receipt)
692                .unwrap_or_else(|error| fatal(&format!("failed to serialize receipt: {error}")))
693        );
694    } else {
695        println!(
696            "persona={} status={} work_key={} queued={}",
697            receipt.persona, receipt.status, receipt.work_key, receipt.queued
698        );
699        if let Some(error) = &receipt.error {
700            println!("error={error}");
701        }
702    }
703}
704
705fn print_validation_errors_json(errors: &[PersonaValidationError]) {
706    let payload = serde_json::json!({
707        "ok": false,
708        "errors": errors.iter().map(|error| {
709            serde_json::json!({
710                "manifest_path": &error.manifest_path,
711                "field_path": &error.field_path,
712                "message": &error.message,
713            })
714        }).collect::<Vec<_>>(),
715    });
716    println!(
717        "{}",
718        serde_json::to_string_pretty(&payload).unwrap_or_else(|error| {
719            fatal(&format!(
720                "failed to serialize persona validation errors: {error}"
721            ))
722        })
723    );
724}
725
726fn persona_to_json(
727    persona: &PersonaManifestEntry,
728    catalog: &ResolvedPersonaManifest,
729) -> serde_json::Value {
730    serde_json::json!({
731        "name": persona.name.as_deref().unwrap_or_default(),
732        "version": persona.version.as_deref(),
733        "description": persona.description.as_deref().unwrap_or_default(),
734        "entry_workflow": persona.entry_workflow.as_deref().unwrap_or_default(),
735        "tools": &persona.tools,
736        "capabilities": &persona.capabilities,
737        "autonomy_tier": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
738        "receipt_policy": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
739        "triggers": &persona.triggers,
740        "schedules": &persona.schedules,
741        "model_policy": {
742            "default_model": persona.model_policy.default_model.as_deref(),
743            "escalation_model": persona.model_policy.escalation_model.as_deref(),
744            "fallback_models": &persona.model_policy.fallback_models,
745            "reasoning_effort": persona.model_policy.reasoning_effort.as_deref(),
746        },
747        "budget": {
748            "daily_usd": persona.budget.daily_usd,
749            "hourly_usd": persona.budget.hourly_usd,
750            "run_usd": persona.budget.run_usd,
751            "frontier_escalations": persona.budget.frontier_escalations,
752            "max_tokens": persona.budget.max_tokens,
753            "max_runtime_seconds": persona.budget.max_runtime_seconds,
754        },
755        "handoffs": &persona.handoffs,
756        "context_packs": &persona.context_packs,
757        "evals": &persona.evals,
758        "steps": &persona.steps,
759        "stages": &persona.stages,
760        "owner": persona.owner.as_deref(),
761        "package_source": {
762            "package": persona.package_source.package.as_deref(),
763            "path": persona.package_source.path.as_deref(),
764            "git": persona.package_source.git.as_deref(),
765            "rev": persona.package_source.rev.as_deref(),
766        },
767        "rollout_policy": {
768            "mode": persona.rollout_policy.mode.as_deref(),
769            "percentage": persona.rollout_policy.percentage,
770            "cohorts": &persona.rollout_policy.cohorts,
771        },
772        "source": {
773            "manifest_path": &catalog.manifest_path,
774            "manifest_dir": &catalog.manifest_dir,
775        },
776    })
777}
778
779fn comma_or_dash(values: &[String]) -> String {
780    if values.is_empty() {
781        "-".to_string()
782    } else {
783        values.join(", ")
784    }
785}
786
787fn fatal(message: &str) -> ! {
788    eprintln!("error: {message}");
789    process::exit(1);
790}