1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
9pub struct PersonaManifestDocument {
10 #[serde(default)]
11 pub personas: Vec<PersonaManifestEntry>,
12}
13
14#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
15pub struct PersonaManifestEntry {
16 #[serde(default)]
17 pub name: Option<String>,
18 #[serde(default)]
19 pub version: Option<String>,
20 #[serde(default)]
21 pub description: Option<String>,
22 #[serde(default, alias = "entry", alias = "entry_pipeline")]
23 pub entry_workflow: Option<String>,
24 #[serde(default)]
25 pub tools: Vec<String>,
26 #[serde(default)]
27 pub capabilities: Vec<String>,
28 #[serde(default, alias = "tier", alias = "autonomy")]
29 pub autonomy_tier: Option<PersonaAutonomyTier>,
30 #[serde(default, alias = "receipts")]
31 pub receipt_policy: Option<PersonaReceiptPolicy>,
32 #[serde(default)]
33 pub triggers: Vec<String>,
34 #[serde(default)]
35 pub schedules: Vec<String>,
36 #[serde(default)]
37 pub model_policy: PersonaModelPolicy,
38 #[serde(default)]
39 pub budget: PersonaBudget,
40 #[serde(default)]
41 pub handoffs: Vec<String>,
42 #[serde(default)]
43 pub context_packs: Vec<String>,
44 #[serde(default, alias = "eval_packs")]
45 pub evals: Vec<String>,
46 #[serde(default)]
47 pub owner: Option<String>,
48 #[serde(default)]
49 pub package_source: PersonaPackageSource,
50 #[serde(default)]
51 pub rollout_policy: PersonaRolloutPolicy,
52 #[serde(flatten, default)]
53 pub extra: BTreeMap<String, toml::Value>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum PersonaAutonomyTier {
59 Shadow,
60 Suggest,
61 ActWithApproval,
62 ActAuto,
63}
64
65impl PersonaAutonomyTier {
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::Shadow => "shadow",
69 Self::Suggest => "suggest",
70 Self::ActWithApproval => "act_with_approval",
71 Self::ActAuto => "act_auto",
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum PersonaReceiptPolicy {
79 #[default]
80 Optional,
81 Required,
82 Disabled,
83}
84
85impl PersonaReceiptPolicy {
86 pub fn as_str(self) -> &'static str {
87 match self {
88 Self::Optional => "optional",
89 Self::Required => "required",
90 Self::Disabled => "disabled",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96pub struct PersonaModelPolicy {
97 #[serde(default)]
98 pub default_model: Option<String>,
99 #[serde(default)]
100 pub escalation_model: Option<String>,
101 #[serde(default)]
102 pub fallback_models: Vec<String>,
103 #[serde(default)]
104 pub reasoning_effort: Option<String>,
105 #[serde(flatten, default)]
106 pub extra: BTreeMap<String, toml::Value>,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110pub struct PersonaBudget {
111 #[serde(default)]
112 pub daily_usd: Option<f64>,
113 #[serde(default)]
114 pub hourly_usd: Option<f64>,
115 #[serde(default)]
116 pub run_usd: Option<f64>,
117 #[serde(default)]
118 pub frontier_escalations: Option<u32>,
119 #[serde(default)]
120 pub max_tokens: Option<u64>,
121 #[serde(default)]
122 pub max_runtime_seconds: Option<u64>,
123 #[serde(flatten, default)]
124 pub extra: BTreeMap<String, toml::Value>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
128pub struct PersonaPackageSource {
129 #[serde(default)]
130 pub package: Option<String>,
131 #[serde(default)]
132 pub path: Option<String>,
133 #[serde(default)]
134 pub git: Option<String>,
135 #[serde(default)]
136 pub rev: Option<String>,
137 #[serde(flatten, default)]
138 pub extra: BTreeMap<String, toml::Value>,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142pub struct PersonaRolloutPolicy {
143 #[serde(default)]
144 pub mode: Option<String>,
145 #[serde(default)]
146 pub percentage: Option<u8>,
147 #[serde(default)]
148 pub cohorts: Vec<String>,
149 #[serde(flatten, default)]
150 pub extra: BTreeMap<String, toml::Value>,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize)]
154pub struct ResolvedPersonaManifest {
155 pub manifest_path: PathBuf,
156 pub manifest_dir: PathBuf,
157 pub personas: Vec<PersonaManifestEntry>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize)]
161pub struct PersonaValidationError {
162 pub manifest_path: PathBuf,
163 pub field_path: String,
164 pub message: String,
165}
166
167impl std::fmt::Display for PersonaValidationError {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 write!(
170 f,
171 "{} {}: {}",
172 self.manifest_path.display(),
173 self.field_path,
174 self.message
175 )
176 }
177}
178
179impl std::error::Error for PersonaValidationError {}
180
181#[derive(Debug, Clone, Default)]
182pub struct PersonaValidationContext {
183 pub known_capabilities: BTreeSet<String>,
184 pub known_tools: BTreeSet<String>,
185 pub known_names: BTreeSet<String>,
186}
187
188pub fn parse_persona_manifest_str(
189 source: &str,
190) -> Result<PersonaManifestDocument, toml::de::Error> {
191 let document = toml::from_str::<PersonaManifestDocument>(source)?;
192 if !document.personas.is_empty() {
193 return Ok(document);
194 }
195 let entry = toml::from_str::<PersonaManifestEntry>(source)?;
196 if entry.name.is_some()
197 || entry.description.is_some()
198 || entry.entry_workflow.is_some()
199 || !entry.tools.is_empty()
200 || !entry.capabilities.is_empty()
201 {
202 Ok(PersonaManifestDocument {
203 personas: vec![entry],
204 })
205 } else {
206 Ok(document)
207 }
208}
209
210pub fn parse_persona_manifest_file(path: &Path) -> Result<PersonaManifestDocument, String> {
211 let content = fs::read_to_string(path)
212 .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
213 parse_persona_manifest_str(&content)
214 .map_err(|error| format!("failed to parse {}: {error}", path.display()))
215}
216
217pub fn validate_persona_manifests(
218 manifest_path: &Path,
219 personas: &[PersonaManifestEntry],
220 context: &PersonaValidationContext,
221) -> Result<(), Vec<PersonaValidationError>> {
222 let mut errors = Vec::new();
223 for (index, persona) in personas.iter().enumerate() {
224 validate_persona(persona, index, manifest_path, context, &mut errors);
225 }
226 if errors.is_empty() {
227 Ok(())
228 } else {
229 Err(errors)
230 }
231}
232
233pub fn validate_persona(
234 persona: &PersonaManifestEntry,
235 index: usize,
236 manifest_path: &Path,
237 context: &PersonaValidationContext,
238 errors: &mut Vec<PersonaValidationError>,
239) {
240 let root = format!("[[personas]][{index}]");
241 for field in persona.extra.keys() {
242 persona_error(
243 manifest_path,
244 format!("{root}.{field}"),
245 "unknown persona field",
246 errors,
247 );
248 }
249 let name = validate_required_string(
250 manifest_path,
251 &root,
252 "name",
253 persona.name.as_deref(),
254 errors,
255 );
256 if let Some(name) = name {
257 validate_tokenish(manifest_path, &root, "name", name, errors);
258 }
259 validate_required_string(
260 manifest_path,
261 &root,
262 "description",
263 persona.description.as_deref(),
264 errors,
265 );
266 validate_required_string(
267 manifest_path,
268 &root,
269 "entry_workflow",
270 persona.entry_workflow.as_deref(),
271 errors,
272 );
273 if persona.tools.is_empty() && persona.capabilities.is_empty() {
274 persona_error(
275 manifest_path,
276 format!("{root}.tools"),
277 "persona requires at least one tool or capability",
278 errors,
279 );
280 }
281 if persona.autonomy_tier.is_none() {
282 persona_error(
283 manifest_path,
284 format!("{root}.autonomy_tier"),
285 "missing required autonomy tier",
286 errors,
287 );
288 }
289 if persona.receipt_policy.is_none() {
290 persona_error(
291 manifest_path,
292 format!("{root}.receipt_policy"),
293 "missing required receipt policy",
294 errors,
295 );
296 }
297 validate_string_list(manifest_path, &root, "tools", &persona.tools, errors);
298 for tool in &persona.tools {
299 if !context.known_tools.is_empty() && !context.known_tools.contains(tool) {
300 persona_error(
301 manifest_path,
302 format!("{root}.tools"),
303 format!("unknown tool '{tool}'"),
304 errors,
305 );
306 }
307 }
308 for capability in &persona.capabilities {
309 let Some((cap, op)) = capability.split_once('.') else {
310 persona_error(
311 manifest_path,
312 format!("{root}.capabilities"),
313 format!("capability '{capability}' must use capability.operation syntax"),
314 errors,
315 );
316 continue;
317 };
318 if cap.trim().is_empty() || op.trim().is_empty() {
319 persona_error(
320 manifest_path,
321 format!("{root}.capabilities"),
322 format!("capability '{capability}' must use capability.operation syntax"),
323 errors,
324 );
325 } else if !context.known_capabilities.is_empty()
326 && !context.known_capabilities.contains(capability)
327 {
328 persona_error(
329 manifest_path,
330 format!("{root}.capabilities"),
331 format!("unknown capability '{capability}'"),
332 errors,
333 );
334 }
335 }
336 validate_string_list(
337 manifest_path,
338 &root,
339 "context_packs",
340 &persona.context_packs,
341 errors,
342 );
343 validate_string_list(manifest_path, &root, "evals", &persona.evals, errors);
344 for schedule in &persona.schedules {
345 if schedule.trim().is_empty() {
346 persona_error(
347 manifest_path,
348 format!("{root}.schedules"),
349 "schedule entries must not be empty",
350 errors,
351 );
352 } else if let Err(error) = croner::Cron::from_str(schedule) {
353 persona_error(
354 manifest_path,
355 format!("{root}.schedules"),
356 format!("invalid cron schedule '{schedule}': {error}"),
357 errors,
358 );
359 }
360 }
361 for trigger in &persona.triggers {
362 match trigger.split_once('.') {
363 Some((provider, event)) if !provider.trim().is_empty() && !event.trim().is_empty() => {}
364 _ => persona_error(
365 manifest_path,
366 format!("{root}.triggers"),
367 format!("trigger '{trigger}' must use provider.event syntax"),
368 errors,
369 ),
370 }
371 }
372 for handoff in &persona.handoffs {
373 if !context.known_names.contains(handoff) {
374 persona_error(
375 manifest_path,
376 format!("{root}.handoffs"),
377 format!("unknown handoff target '{handoff}'"),
378 errors,
379 );
380 }
381 }
382 validate_persona_budget(manifest_path, &root, &persona.budget, errors);
383 validate_persona_nested_extra(
384 manifest_path,
385 &root,
386 "model_policy",
387 &persona.model_policy.extra,
388 errors,
389 );
390 validate_persona_nested_extra(
391 manifest_path,
392 &root,
393 "package_source",
394 &persona.package_source.extra,
395 errors,
396 );
397 validate_persona_nested_extra(
398 manifest_path,
399 &root,
400 "rollout_policy",
401 &persona.rollout_policy.extra,
402 errors,
403 );
404 if let Some(percentage) = persona.rollout_policy.percentage {
405 if percentage > 100 {
406 persona_error(
407 manifest_path,
408 format!("{root}.rollout_policy.percentage"),
409 "rollout percentage must be between 0 and 100",
410 errors,
411 );
412 }
413 }
414}
415
416pub fn validate_required_string<'a>(
417 manifest_path: &Path,
418 root: &str,
419 field: &str,
420 value: Option<&'a str>,
421 errors: &mut Vec<PersonaValidationError>,
422) -> Option<&'a str> {
423 match value.map(str::trim) {
424 Some(value) if !value.is_empty() => Some(value),
425 _ => {
426 persona_error(
427 manifest_path,
428 format!("{root}.{field}"),
429 format!("missing required {field}"),
430 errors,
431 );
432 None
433 }
434 }
435}
436
437pub fn validate_string_list(
438 manifest_path: &Path,
439 root: &str,
440 field: &str,
441 values: &[String],
442 errors: &mut Vec<PersonaValidationError>,
443) {
444 for value in values {
445 if value.trim().is_empty() {
446 persona_error(
447 manifest_path,
448 format!("{root}.{field}"),
449 format!("{field} entries must not be empty"),
450 errors,
451 );
452 } else {
453 validate_tokenish(manifest_path, root, field, value, errors);
454 }
455 }
456}
457
458pub fn validate_tokenish(
459 manifest_path: &Path,
460 root: &str,
461 field: &str,
462 value: &str,
463 errors: &mut Vec<PersonaValidationError>,
464) {
465 if !value
466 .chars()
467 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/'))
468 {
469 persona_error(
470 manifest_path,
471 format!("{root}.{field}"),
472 format!("'{value}' must contain only letters, numbers, '.', '-', '_', or '/'"),
473 errors,
474 );
475 }
476}
477
478pub fn validate_persona_budget(
479 manifest_path: &Path,
480 root: &str,
481 budget: &PersonaBudget,
482 errors: &mut Vec<PersonaValidationError>,
483) {
484 validate_persona_nested_extra(manifest_path, root, "budget", &budget.extra, errors);
485 for (field, value) in [
486 ("daily_usd", budget.daily_usd),
487 ("hourly_usd", budget.hourly_usd),
488 ("run_usd", budget.run_usd),
489 ] {
490 if value.is_some_and(|number| !number.is_finite() || number < 0.0) {
491 persona_error(
492 manifest_path,
493 format!("{root}.budget.{field}"),
494 "budget amounts must be finite non-negative numbers",
495 errors,
496 );
497 }
498 }
499}
500
501pub fn validate_persona_nested_extra(
502 manifest_path: &Path,
503 root: &str,
504 field: &str,
505 extra: &BTreeMap<String, toml::Value>,
506 errors: &mut Vec<PersonaValidationError>,
507) {
508 for key in extra.keys() {
509 persona_error(
510 manifest_path,
511 format!("{root}.{field}.{key}"),
512 format!("unknown {field} field"),
513 errors,
514 );
515 }
516}
517
518pub fn persona_error(
519 manifest_path: &Path,
520 field_path: String,
521 message: impl Into<String>,
522 errors: &mut Vec<PersonaValidationError>,
523) {
524 errors.push(PersonaValidationError {
525 manifest_path: manifest_path.to_path_buf(),
526 field_path,
527 message: message.into(),
528 });
529}
530
531pub fn default_persona_capability_map() -> BTreeMap<&'static str, Vec<&'static str>> {
532 BTreeMap::from([
533 (
534 "workspace",
535 vec![
536 "read_text",
537 "write_text",
538 "apply_edit",
539 "delete",
540 "exists",
541 "file_exists",
542 "list",
543 "project_root",
544 "roots",
545 ],
546 ),
547 ("process", vec!["exec"]),
548 ("template", vec!["render"]),
549 ("interaction", vec!["ask"]),
550 (
551 "runtime",
552 vec![
553 "approved_plan",
554 "dry_run",
555 "pipeline_input",
556 "record_run",
557 "set_result",
558 "task",
559 ],
560 ),
561 (
562 "project",
563 vec![
564 "agent_instructions",
565 "code_patterns",
566 "compute_content_hash",
567 "ide_context",
568 "lessons",
569 "mcp_config",
570 "metadata_get",
571 "metadata_refresh_hashes",
572 "metadata_save",
573 "metadata_set",
574 "metadata_stale",
575 "scan",
576 "scope_test_command",
577 "test_commands",
578 ],
579 ),
580 (
581 "session",
582 vec![
583 "active_roots",
584 "changed_paths",
585 "preread_get",
586 "preread_read_many",
587 ],
588 ),
589 (
590 "editor",
591 vec!["get_active_file", "get_selection", "get_visible_files"],
592 ),
593 ("diagnostics", vec!["get_causal_traces", "get_errors"]),
594 ("git", vec!["get_branch", "get_diff"]),
595 ("learning", vec!["get_learned_rules", "report_correction"]),
596 ])
597}
598
599pub fn default_persona_capabilities() -> BTreeSet<String> {
600 let mut capabilities = BTreeSet::new();
601 for (capability, operations) in default_persona_capability_map() {
602 for operation in operations {
603 capabilities.insert(format!("{capability}.{operation}"));
604 }
605 }
606 capabilities
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 fn context(names: &[&str]) -> PersonaValidationContext {
614 PersonaValidationContext {
615 known_capabilities: default_persona_capabilities(),
616 known_tools: BTreeSet::from(["github".to_string(), "ci".to_string()]),
617 known_names: names.iter().map(|name| name.to_string()).collect(),
618 }
619 }
620
621 #[test]
622 fn validates_sample_manifest() {
623 let parsed = parse_persona_manifest_str(
624 r#"
625[[personas]]
626name = "merge_captain"
627description = "Owns PR readiness."
628entry_workflow = "workflows/merge_captain.harn#run"
629tools = ["github", "ci"]
630capabilities = ["git.get_diff"]
631autonomy = "act_with_approval"
632receipts = "required"
633triggers = ["github.pr_opened"]
634schedules = ["*/30 * * * *"]
635handoffs = ["review_captain"]
636context_packs = ["repo_policy"]
637evals = ["merge_safety"]
638budget = { daily_usd = 20.0 }
639
640[[personas]]
641name = "review_captain"
642description = "Reviews code."
643entry_workflow = "workflows/review_captain.harn#run"
644tools = ["github"]
645autonomy_tier = "suggest"
646receipt_policy = "optional"
647"#,
648 )
649 .expect("manifest parses");
650
651 validate_persona_manifests(
652 Path::new("harn.toml"),
653 &parsed.personas,
654 &context(&["merge_captain", "review_captain"]),
655 )
656 .expect("manifest validates");
657 }
658
659 #[test]
660 fn bad_manifest_produces_typed_errors() {
661 let parsed = parse_persona_manifest_str(
662 r#"
663[[personas]]
664name = "bad"
665description = ""
666entry_workflow = ""
667tools = ["unknown"]
668capabilities = ["git"]
669autonomy = "shadow"
670receipts = "required"
671triggers = ["github"]
672schedules = [""]
673handoffs = ["missing"]
674budget = { daily_usd = -1.0, surprise = true }
675surprise = true
676"#,
677 )
678 .expect("manifest parses");
679
680 let errors = validate_persona_manifests(
681 Path::new("harn.toml"),
682 &parsed.personas,
683 &context(&["bad"]),
684 )
685 .expect_err("manifest rejects");
686 let fields: BTreeSet<_> = errors
687 .iter()
688 .map(|error| error.field_path.as_str())
689 .collect();
690 assert!(fields.contains("[[personas]][0].description"));
691 assert!(fields.contains("[[personas]][0].entry_workflow"));
692 assert!(fields.contains("[[personas]][0].tools"));
693 assert!(fields.contains("[[personas]][0].capabilities"));
694 assert!(fields.contains("[[personas]][0].triggers"));
695 assert!(fields.contains("[[personas]][0].schedules"));
696 assert!(fields.contains("[[personas]][0].handoffs"));
697 assert!(fields.contains("[[personas]][0].budget.daily_usd"));
698 assert!(fields.contains("[[personas]][0].budget.surprise"));
699 assert!(fields.contains("[[personas]][0].surprise"));
700 }
701}