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