use std::fs;
use std::path::{Path, PathBuf};
use crate::cli::{PersonaNewArgs, PersonaTemplateKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersonaScaffoldResult {
pub root: PathBuf,
pub files: Vec<PathBuf>,
}
#[derive(Clone, Copy)]
struct TemplateFile {
relative_path: &'static str,
content: &'static str,
}
const TEMPLATE_FILES: &[TemplateFile] = &[
TemplateFile {
relative_path: "harn.toml",
content: HARN_TOML_TEMPLATE,
},
TemplateFile {
relative_path: "src/{{persona_name}}.harn",
content: SOURCE_TEMPLATE,
},
TemplateFile {
relative_path: "tests/{{persona_name}}_smoke.harn",
content: SMOKE_TEST_TEMPLATE,
},
TemplateFile {
relative_path: "tests/{{persona_name}}_smoke.expected",
content: SMOKE_EXPECTED_TEMPLATE,
},
TemplateFile {
relative_path: "fixtures/happy_path.json",
content: FIXTURE_TEMPLATE,
},
TemplateFile {
relative_path: "prompts/system.harn.prompt",
content: PROMPT_TEMPLATE,
},
TemplateFile {
relative_path: "evals/smoke.eval.json",
content: EVAL_TEMPLATE,
},
TemplateFile {
relative_path: "README.md",
content: README_TEMPLATE,
},
];
const HARN_TOML_TEMPLATE: &str = r#"[package]
name = "harn-{{package_slug}}-persona"
version = "0.1.0"
description = "{{persona_title}} persona scaffold."
[[personas]]
name = "{{persona_name}}"
version = "0.1.0"
description = "{{persona_title}} persona scaffold generated from the {{template_kind}} template."
entry_workflow = "src/{{persona_name}}.harn#run"
tools = {{tools}}
capabilities = {{capabilities}}
autonomy_tier = "{{autonomy_tier}}"
receipt_policy = "required"
triggers = {{triggers}}
schedules = []
handoffs = []
context_packs = ["repo_policy"]
evals = ["smoke"]
owner = "platform"
budget = {{budget}}
model_policy = {{model_policy}}
rollout_policy = { mode = "approval_only", percentage = 100, cohorts = ["scaffold"] }
package_source = { package = "harn-{{package_slug}}-persona", path = "." }
"#;
const SOURCE_TEMPLATE: &str = r#"import { bounded_loop, cheap_classify_then_escalate, with_audit_receipt } from "std/personas/prelude"
@persona(
name: "{{persona_name}}",
description: "{{persona_title}} persona scaffold generated from the {{template_kind}} template.",
tools: {{tools_symbols}},
capabilities: {{capabilities}},
autonomy: "{{autonomy_tier}}",
receipts: "required",
)
/** Run the scaffolded persona DAG. */
pub fn {{persona_ident}}(task) {
{{persona_body}}
}
{{steps}}
pipeline run(task) {
return {{persona_ident}}(task)
}
"#;
const SMOKE_TEST_TEMPLATE: &str = r#"import { {{persona_ident}} } from "../src/{{persona_name}}"
pipeline test_{{persona_ident}}_smoke(task) {
llm_mock_clear()
{{llm_mocks}}
let fixture = json_parse(read_file("../fixtures/happy_path.json"))
let result = {{persona_ident}}({
fixture: fixture,
started_at: "2026-05-05T00:00:00Z",
completed_at: "2026-05-05T00:00:00Z",
})
assert_eq(result.ok, true)
assert_eq(result.receipt.schema, "harn.receipt.v1")
{{smoke_assertions}}
println(result.ok)
println(result.receipt.schema)
}
"#;
const SMOKE_EXPECTED_TEMPLATE: &str = r#"true
harn.receipt.v1
"#;
const FIXTURE_TEMPLATE: &str = r#"{
"items": [
{"id": "demo-1", "title": "First scaffolded work item", "risk": "low"},
{"id": "demo-2", "title": "Second scaffolded work item", "risk": "medium"}
],
"request": "Decide the next safe action for this persona package."
}
"#;
const PROMPT_TEMPLATE: &str = r#"You are {{persona_title}}, a Harn persona.
Use the fixture request and typed step metadata to produce a bounded, auditable decision.
Persona: {{persona_name}}
Template: {{template_kind}}
Request: {{request}}
"#;
const EVAL_TEMPLATE: &str = r#"{
"_type": "eval_pack_manifest",
"id": "{{persona_name}}-smoke",
"persona": "{{persona_name}}",
"fixtures": ["fixtures/happy_path.json"],
"metrics": [
{"name": "smoke_passed", "kind": "boolean"}
]
}
"#;
const README_TEMPLATE: &str = r#"# {{persona_title}}
Generated from the `{{template_kind}}` persona template.
## Validate
```bash
harn persona doctor {{persona_name}}
```
## Run The Smoke Test
```bash
harn test tests/{{persona_name}}_smoke.harn
```
## Package Layout
- `harn.toml` declares the durable persona manifest and budget/model defaults.
- `src/{{persona_name}}.harn` contains the `@persona` function and typed `@step` DAG skeleton.
- `prompts/system.harn.prompt` keeps model-facing instructions as a prompt asset.
- `fixtures/happy_path.json`, `tests/{{persona_name}}_smoke.harn`, and `tests/{{persona_name}}_smoke.expected` form the first eval-ready fixture pair.
"#;
pub(crate) fn scaffold_persona(args: &PersonaNewArgs) -> Result<PersonaScaffoldResult, String> {
let name = normalize_name(&args.name)?;
let target_root = args.output_root.join(&name);
if target_root.exists() {
if !args.force {
return Err(format!(
"{} already exists; pass --force to replace generated files",
target_root.display()
));
}
fs::remove_dir_all(&target_root).map_err(|error| {
format!(
"failed to remove existing persona package {}: {error}",
target_root.display()
)
})?;
}
let vars = TemplateVars::new(&name, args.template);
let mut files = Vec::new();
for template in TEMPLATE_FILES {
let relative = render(template.relative_path, &vars);
let path = target_root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
}
let content = render(template.content, &vars);
fs::write(&path, content)
.map_err(|error| format!("failed to write {}: {error}", path.display()))?;
files.push(path);
}
Ok(PersonaScaffoldResult {
root: target_root,
files,
})
}
pub fn scaffold_persona_package(
name: &str,
template: &str,
output_root: &Path,
force: bool,
) -> Result<PersonaScaffoldResult, String> {
let args = PersonaNewArgs {
name: name.to_string(),
template: parse_template_kind(template)?,
output_root: output_root.to_path_buf(),
force,
};
scaffold_persona(&args)
}
pub(crate) fn run_new(args: &PersonaNewArgs) -> Result<(), String> {
let result = scaffold_persona(args)?;
println!("created persona package {}", result.root.display());
for file in &result.files {
println!(" create {}", file.display());
}
println!();
println!(" harn persona doctor {}", normalize_name(&args.name)?);
Ok(())
}
fn normalize_name(raw: &str) -> Result<String, String> {
let name = raw.trim().replace('-', "_");
if !valid_identifier(&name) {
return Err(format!(
"persona name must be an identifier-like token, got {raw:?}"
));
}
Ok(name)
}
fn valid_identifier(value: &str) -> bool {
let mut chars = value.chars();
match chars.next() {
Some(ch) if ch == '_' || ch.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn parse_template_kind(value: &str) -> Result<PersonaTemplateKind, String> {
match value {
"deterministic-sweeper" => Ok(PersonaTemplateKind::DeterministicSweeper),
"hybrid-classify-then-act" => Ok(PersonaTemplateKind::HybridClassifyThenAct),
"frontier-judgment-loop" => Ok(PersonaTemplateKind::FrontierJudgmentLoop),
_ => Err(format!("unknown persona template {value:?}")),
}
}
fn to_title(name: &str) -> String {
name.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn to_package_slug(name: &str) -> String {
name.replace('_', "-")
}
struct TemplateVars {
persona_name: String,
persona_ident: String,
persona_title: String,
package_slug: String,
template_kind: &'static str,
tools: &'static str,
tools_symbols: &'static str,
capabilities: &'static str,
autonomy_tier: &'static str,
triggers: &'static str,
budget: &'static str,
model_policy: &'static str,
persona_body: &'static str,
steps: &'static str,
llm_mocks: &'static str,
smoke_assertions: &'static str,
}
impl TemplateVars {
fn new(name: &str, kind: PersonaTemplateKind) -> Self {
let shape = TemplateShape::for_kind(kind);
Self {
persona_name: name.to_string(),
persona_ident: name.to_string(),
persona_title: to_title(name),
package_slug: to_package_slug(name),
template_kind: kind.as_str(),
tools: shape.tools,
tools_symbols: shape.tools_symbols,
capabilities: shape.capabilities,
autonomy_tier: shape.autonomy_tier,
triggers: shape.triggers,
budget: shape.budget,
model_policy: shape.model_policy,
persona_body: shape.persona_body,
steps: shape.steps,
llm_mocks: shape.llm_mocks,
smoke_assertions: shape.smoke_assertions,
}
}
}
struct TemplateShape {
tools: &'static str,
tools_symbols: &'static str,
capabilities: &'static str,
autonomy_tier: &'static str,
triggers: &'static str,
budget: &'static str,
model_policy: &'static str,
persona_body: &'static str,
steps: &'static str,
llm_mocks: &'static str,
smoke_assertions: &'static str,
}
impl TemplateShape {
fn for_kind(kind: PersonaTemplateKind) -> Self {
match kind {
PersonaTemplateKind::DeterministicSweeper => Self {
tools: r#"["github"]"#,
tools_symbols: "[github]",
capabilities: r#"["project.test_commands", "runtime.record_run"]"#,
autonomy_tier: "act_with_approval",
triggers: r#"["github.pr_opened"]"#,
budget: "{ daily_usd = 2.0, run_usd = 0.05, max_tokens = 10000, max_runtime_seconds = 120 }",
model_policy: r#"{ default_model = "mock", escalation_model = "mock", fallback_models = ["mock"], reasoning_effort = "low" }"#,
persona_body: " return drain_queue(task)",
steps: DETERMINISTIC_STEPS,
llm_mocks: "",
smoke_assertions: " assert_eq(result.status, \"completed\")\n assert_eq(len(result.state.processed), 2)",
},
PersonaTemplateKind::HybridClassifyThenAct => Self {
tools: r#"["github", "mcp"]"#,
tools_symbols: "[github, mcp]",
capabilities: r#"["project.scan", "runtime.record_run"]"#,
autonomy_tier: "act_with_approval",
triggers: r#"["github.issue_opened"]"#,
budget: "{ daily_usd = 3.0, run_usd = 0.10, frontier_escalations = 1, max_tokens = 16000, max_runtime_seconds = 180 }",
model_policy: r#"{ default_model = "mock", escalation_model = "mock", fallback_models = ["mock"], reasoning_effort = "medium" }"#,
persona_body: " let classified = classify_request(task)\n return act_on_classification(classified)",
steps: HYBRID_STEPS,
llm_mocks: "",
smoke_assertions: " assert_eq(result.status, \"success\")\n assert_eq(result.result.action, \"draft_plan\")",
},
PersonaTemplateKind::FrontierJudgmentLoop => Self {
tools: r#"["github", "mcp"]"#,
tools_symbols: "[github, mcp]",
capabilities: r#"["project.scan", "runtime.record_run", "interaction.ask"]"#,
autonomy_tier: "suggest",
triggers: r#"["manual.review_requested"]"#,
budget: "{ daily_usd = 5.0, run_usd = 0.25, frontier_escalations = 2, max_tokens = 24000, max_runtime_seconds = 300 }",
model_policy: r#"{ default_model = "mock", escalation_model = "mock", fallback_models = ["mock"], reasoning_effort = "medium" }"#,
persona_body: " return judge_until_confident(task)",
steps: FRONTIER_STEPS,
llm_mocks: " llm_mock({text: \"{\\\"decision\\\": \\\"ship\\\", \\\"confidence\\\": 0.92, \\\"rationale\\\": \\\"fixture is low risk\\\"}\"})",
smoke_assertions: " assert_eq(result.status, \"completed\")\n assert_eq(result.state.decision, \"ship\")",
},
}
}
}
const DETERMINISTIC_STEPS: &str = r#"@step(name: "drain_queue", receipt: audit, error_boundary: fail, budget: {max_tokens: 1000})
fn drain_queue(task) {
let fixture = task?.fixture ?? {items: []}
let prompt = render_prompt("../prompts/system.harn.prompt", {
persona_name: "{{persona_name}}",
template_kind: "{{template_kind}}",
request: fixture?.request ?? "drain queued work",
},)
return bounded_loop(
{remaining: fixture.items, processed: []},
fn(state) {
if len(state.remaining) == 0 {
return {state: state, progress: false, done: true}
}
let item = state.remaining[0]
let rest = state.remaining[1:]
return {
state: {remaining: rest, processed: state.processed + [item]},
progress: true,
done: len(rest) == 0,
}
},
{
persona: "{{persona_name}}",
step: "drain_queue",
started_at: task?.started_at,
completed_at: task?.completed_at,
max_iterations: 20,
progress_required: true,
metadata: {prompt_digest: sha256(prompt)},
},
)
}"#;
const HYBRID_STEPS: &str = r#"@step(name: "classify_request", model: "mock", receipt: audit, error_boundary: escalate, budget: {max_tokens: 2000})
fn classify_request(task) {
let fixture = task?.fixture ?? {}
let prompt = render_prompt("../prompts/system.harn.prompt", {
persona_name: "{{persona_name}}",
template_kind: "{{template_kind}}",
request: fixture?.request ?? "classify requested work",
},)
return cheap_classify_then_escalate(
fixture,
fn(input) { return {label: "needs_plan", confidence: 0.62, item_count: len(input?.items ?? [])} },
fn(input) { return {label: "draft_plan", confidence: 0.91, item_count: len(input?.items ?? [])} },
fn(result) { return result.confidence < 0.7 || result.label == "needs_plan" },
{
persona: "{{persona_name}}",
step: "classify_request",
started_at: task?.started_at,
completed_at: task?.completed_at,
min_confidence: 0.7,
metadata: {prompt_digest: sha256(prompt)},
},
)
}
@step(name: "act_on_classification", receipt: audit, error_boundary: fail, budget: {max_tokens: 1000})
fn act_on_classification(classified) {
let wrapped = with_audit_receipt(
fn() {
return {
action: classified.result.label,
escalated: classified.escalated,
confidence: classified.result.confidence,
}
},
{persona: "{{persona_name}}", step: "act_on_classification"},
)
return wrapped()
}"#;
const FRONTIER_STEPS: &str = r#"@step(name: "judge_until_confident", model: "mock", receipt: audit, error_boundary: escalate, retry: {max_attempts: 2}, budget: {max_tokens: 4000})
fn judge_until_confident(task) {
let fixture = task?.fixture ?? {}
let schema = {
type: "object",
required: ["decision", "confidence", "rationale"],
properties: {
decision: {type: "string"},
confidence: {type: "number"},
rationale: {type: "string"},
},
}
return bounded_loop(
{decision: nil, confidence: 0.0, attempts: 0},
fn(state) {
let prompt = render_prompt("../prompts/system.harn.prompt", {
persona_name: "{{persona_name}}",
template_kind: "{{template_kind}}",
request: fixture?.request ?? "review",
},)
let result = llm_call_structured_safe(prompt, schema, {
provider: "mock",
model: "mock",
retries: 0,
})
if !result.ok {
return {
state: state + {attempts: state.attempts + 1, error: result.error},
progress: true,
done: state.attempts + 1 >= 2,
}
}
return {
state: result.data + {attempts: state.attempts + 1},
progress: true,
done: result.data.confidence >= 0.8,
}
},
{
persona: "{{persona_name}}",
step: "judge_until_confident",
started_at: task?.started_at,
completed_at: task?.completed_at,
max_iterations: 2,
progress_required: true,
},
)
}"#;
fn render(template: &str, vars: &TemplateVars) -> String {
template
.replace("{{persona_body}}", vars.persona_body)
.replace("{{steps}}", vars.steps)
.replace("{{llm_mocks}}", vars.llm_mocks)
.replace("{{smoke_assertions}}", vars.smoke_assertions)
.replace("{{persona_name}}", &vars.persona_name)
.replace("{{persona_ident}}", &vars.persona_ident)
.replace("{{persona_title}}", &vars.persona_title)
.replace("{{package_slug}}", &vars.package_slug)
.replace("{{template_kind}}", vars.template_kind)
.replace("{{tools}}", vars.tools)
.replace("{{tools_symbols}}", vars.tools_symbols)
.replace("{{capabilities}}", vars.capabilities)
.replace("{{autonomy_tier}}", vars.autonomy_tier)
.replace("{{triggers}}", vars.triggers)
.replace("{{budget}}", vars.budget)
.replace("{{model_policy}}", vars.model_policy)
}
#[allow(dead_code)]
pub(crate) fn template_dir_for_docs(kind: PersonaTemplateKind) -> PathBuf {
Path::new("personas").join("_templates").join(kind.as_str())
}