From 228ce89e4470ee5e03a6bb9c41bf64e8a73c53ad Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:20:08 -0400
Subject: [PATCH 01/28] fix(persona): respond_to shallow replacement (S1) +
 subscribe merge tests (S2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Spec amendment S1: When a persona sets respond_to, the entire object
replaces the pack default. Missing sub-fields fall to built-in defaults
(mentions: true, keywords: [], all_messages: false), not pack defaults.

This simplifies the merge logic significantly — removes the complex
field-level merge in favor of a clean shallow replacement pattern
consistent with how all other fields work.

Also adds subscribe merge tests for S2 three-state semantics
(null → fallthrough, [] → override, absent → fallthrough).

Tests: 6 new respond_to tests, 3 new subscribe tests. All 120 pass.
---
 crates/sprout-persona/src/merge.rs | 417 +++++++++++++++++++++++++++++
 1 file changed, 417 insertions(+)
 create mode 100644 crates/sprout-persona/src/merge.rs

diff --git a/crates/sprout-persona/src/merge.rs b/crates/sprout-persona/src/merge.rs
new file mode 100644
index 0000000000..b1175942d7
--- /dev/null
+++ b/crates/sprout-persona/src/merge.rs
@@ -0,0 +1,417 @@
+/// Precedence resolution for persona behavioral config.
+///
+/// Handles levels 3–5 of the 5-level precedence model:
+///   3. Per-persona frontmatter  (wins)
+///   4. Pack-level defaults      (from plugin.json `defaults`)
+///   5. Built-in defaults        (hardcoded fallbacks)
+///
+/// Levels 1–2 (operator env vars, desktop UI) are resolved at runtime.
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct RespondToData {
+    pub mentions: bool,
+    pub keywords: Vec<String>,
+    pub all_messages: bool,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct HooksData {
+    pub on_start: Option<String>,
+    pub on_stop: Option<String>,
+    pub on_message: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedConfig {
+    pub model: Option<String>,
+    pub temperature: Option<f64>,
+    pub max_context_tokens: Option<u64>,
+    pub subscribe: Vec<String>,
+    pub respond_to: Option<RespondToData>,
+    pub thread_replies: bool,
+    pub broadcast_replies: bool,
+}
+
+// ── Built-in defaults ─────────────────────────────────────────────────────────
+
+const DEFAULT_THREAD_REPLIES: bool = true;
+const DEFAULT_BROADCAST_REPLIES: bool = false;
+
+// ── Core merge ────────────────────────────────────────────────────────────────
+
+/// Merge pack defaults with per-persona values.
+///
+/// Rules:
+/// - Persona field present (non-null) → use persona value
+/// - Persona field absent or null     → use pack default
+/// - Empty array `[]` or object `{}`  → present, overrides default
+pub fn merge_behavioral_config(
+    persona_config: &serde_json::Value,
+    pack_defaults: &serde_json::Value,
+) -> serde_json::Value {
+    use serde_json::Value;
+
+    let persona_obj = match persona_config.as_object() {
+        Some(o) => o,
+        None => return pack_defaults.clone(),
+    };
+    let defaults_obj = match pack_defaults.as_object() {
+        Some(o) => o,
+        None => return persona_config.clone(),
+    };
+
+    let mut merged = serde_json::Map::new();
+
+    // All keys from defaults first, then persona overrides.
+    for (key, default_val) in defaults_obj {
+        let effective = match persona_obj.get(key) {
+            // null in persona → fall through to default
+            Some(Value::Null) | None => default_val.clone(),
+            Some(v) => v.clone(),
+        };
+        merged.insert(key.clone(), effective);
+    }
+
+    // Any persona keys not in defaults are included as-is (excluding null).
+    for (key, val) in persona_obj {
+        if !merged.contains_key(key) && !val.is_null() {
+            merged.insert(key.clone(), val.clone());
+        }
+    }
+
+    Value::Object(merged)
+}
+
+// ── High-level resolver ───────────────────────────────────────────────────────
+
+/// Resolve a single persona's effective config from raw frontmatter + pack defaults.
+pub fn resolve_persona_config(
+    persona_frontmatter: &serde_json::Value,
+    pack_defaults: Option<&serde_json::Value>,
+) -> ResolvedConfig {
+    let empty = serde_json::Value::Object(serde_json::Map::new());
+    let defaults = pack_defaults.unwrap_or(&empty);
+    let merged = merge_behavioral_config(persona_frontmatter, defaults);
+
+    let model = string_field(&merged, "model");
+    let temperature = merged.get("temperature").and_then(|v| v.as_f64());
+    let max_context_tokens = merged.get("max_context_tokens").and_then(|v| v.as_u64());
+
+    let subscribe = merged
+        .get("subscribe")
+        .and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|v| v.as_str().map(str::to_owned))
+                .collect()
+        })
+        .unwrap_or_default();
+
+    // respond_to: SHALLOW REPLACEMENT (spec amendment S1).
+    //
+    // If persona has respond_to (non-null), it replaces the pack default
+    // entirely. Missing sub-fields fall to BUILT-IN defaults, not pack
+    // defaults. This is consistent with how subscribe and other fields work.
+    //
+    // If persona lacks respond_to (null or absent), use pack default.
+    // If neither has it, None.
+    let respond_to = {
+        let persona_rt = persona_frontmatter.get("respond_to");
+        let default_rt = defaults.get("respond_to");
+        match (persona_rt, default_rt) {
+            // Neither side has respond_to.
+            (None, None)
+            | (Some(serde_json::Value::Null), None)
+            | (None, Some(serde_json::Value::Null))
+            | (Some(serde_json::Value::Null), Some(serde_json::Value::Null)) => None,
+            // Persona has respond_to — use it directly (shallow replacement).
+            // Pack default is ignored entirely.
+            (Some(v), _) if !v.is_null() => parse_respond_to(v),
+            // Persona absent/null — fall through to pack default.
+            (None, Some(v)) | (Some(serde_json::Value::Null), Some(v)) => parse_respond_to(v),
+            _ => None,
+        }
+    };
+
+    let thread_replies = merged
+        .get("thread_replies")
+        .and_then(|v| v.as_bool())
+        .unwrap_or(DEFAULT_THREAD_REPLIES);
+
+    let broadcast_replies = merged
+        .get("broadcast_replies")
+        .and_then(|v| v.as_bool())
+        .unwrap_or(DEFAULT_BROADCAST_REPLIES);
+
+    ResolvedConfig {
+        model,
+        temperature,
+        max_context_tokens,
+        subscribe,
+        respond_to,
+        thread_replies,
+        broadcast_replies,
+    }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+fn string_field(v: &serde_json::Value, key: &str) -> Option<String> {
+    v.get(key).and_then(|v| v.as_str()).map(str::to_owned)
+}
+
+fn parse_respond_to(v: &serde_json::Value) -> Option<RespondToData> {
+    let obj = v.as_object()?;
+    Some(RespondToData {
+        mentions: obj
+            .get("mentions")
+            .and_then(|v| v.as_bool())
+            .unwrap_or(true),
+        keywords: obj
+            .get("keywords")
+            .and_then(|v| v.as_array())
+            .map(|arr| {
+                arr.iter()
+                    .filter_map(|v| v.as_str().map(str::to_owned))
+                    .collect()
+            })
+            .unwrap_or_default(),
+        all_messages: obj
+            .get("all_messages")
+            .and_then(|v| v.as_bool())
+            .unwrap_or(false),
+    })
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+
+    // ── merge_behavioral_config ───────────────────────────────────────────────
+
+    #[test]
+    fn persona_value_wins_over_pack_default() {
+        let persona = json!({ "model": "gpt-4o", "thread_replies": false });
+        let defaults = json!({ "model": "claude-3", "thread_replies": true });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["model"], "gpt-4o");
+        assert_eq!(merged["thread_replies"], false);
+    }
+
+    #[test]
+    fn pack_default_used_when_persona_field_absent() {
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({ "model": "claude-3", "temperature": 0.7 });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["model"], "gpt-4o");
+        assert_eq!(merged["temperature"], 0.7);
+    }
+
+    #[test]
+    fn null_persona_value_falls_through_to_default() {
+        let persona = json!({ "model": null });
+        let defaults = json!({ "model": "claude-3" });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["model"], "claude-3");
+    }
+
+    #[test]
+    fn empty_array_overrides_default() {
+        let persona = json!({ "subscribe": [] });
+        let defaults = json!({ "subscribe": ["channel-a", "channel-b"] });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["subscribe"], json!([]));
+    }
+
+    #[test]
+    fn empty_object_overrides_default() {
+        let persona = json!({ "respond_to": {} });
+        let defaults = json!({ "respond_to": { "mentions": true } });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["respond_to"], json!({}));
+    }
+
+    #[test]
+    fn no_defaults_persona_fields_pass_through() {
+        let persona = json!({ "model": "gpt-4o", "temperature": 0.5 });
+        let defaults = json!({});
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["model"], "gpt-4o");
+        assert_eq!(merged["temperature"], 0.5);
+    }
+
+    #[test]
+    fn full_merge_mixed_present_absent() {
+        let persona = json!({
+            "model": "gpt-4o",
+            "temperature": null,
+            "subscribe": ["chan-x"],
+        });
+        let defaults = json!({
+            "model": "claude-3",
+            "temperature": 0.9,
+            "thread_replies": false,
+            "subscribe": ["chan-default"],
+        });
+        let merged = merge_behavioral_config(&persona, &defaults);
+        assert_eq!(merged["model"], "gpt-4o");           // persona wins
+        assert_eq!(merged["temperature"], 0.9);          // null → default
+        assert_eq!(merged["thread_replies"], false);     // default used
+        assert_eq!(merged["subscribe"], json!(["chan-x"])); // persona wins
+    }
+
+    // ── resolve_persona_config ────────────────────────────────────────────────
+
+    #[test]
+    fn built_in_defaults_when_no_fields() {
+        let persona = json!({});
+        let resolved = resolve_persona_config(&persona, None);
+        assert_eq!(resolved.model, None);
+        assert_eq!(resolved.temperature, None);
+        assert_eq!(resolved.max_context_tokens, None);
+        assert!(resolved.subscribe.is_empty());
+        assert_eq!(resolved.respond_to, None);
+        assert_eq!(resolved.thread_replies, true);    // built-in default
+        assert_eq!(resolved.broadcast_replies, false); // built-in default
+    }
+
+    #[test]
+    fn resolve_with_pack_defaults() {
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({
+            "temperature": 0.7,
+            "thread_replies": false,
+            "subscribe": ["general"],
+        });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        assert_eq!(resolved.model.as_deref(), Some("gpt-4o"));
+        assert_eq!(resolved.temperature, Some(0.7));
+        assert_eq!(resolved.thread_replies, false);
+        assert_eq!(resolved.subscribe, vec!["general"]);
+    }
+
+    #[test]
+    fn resolve_respond_to_parsed() {
+        let persona = json!({
+            "respond_to": {
+                "mentions": true,
+                "keywords": ["help", "sprout"],
+                "all_messages": false,
+            }
+        });
+        let resolved = resolve_persona_config(&persona, None);
+        let rt = resolved.respond_to.unwrap();
+        assert!(rt.mentions);
+        assert_eq!(rt.keywords, vec!["help", "sprout"]);
+        assert!(!rt.all_messages);
+    }
+
+    #[test]
+    fn resolve_max_context_tokens() {
+        let persona = json!({ "max_context_tokens": 8192u64 });
+        let resolved = resolve_persona_config(&persona, None);
+        assert_eq!(resolved.max_context_tokens, Some(8192));
+    }
+
+    // ── respond_to shallow replacement (S1) ─────────────────────────────────
+
+    #[test]
+    fn respond_to_shallow_replacement_loses_pack_keywords() {
+        // S1: Persona sets `mentions: false` — entire respond_to replaces pack default.
+        // Pack's `keywords: ["foo"]` is LOST. Missing sub-fields get built-in defaults.
+        let persona = json!({ "respond_to": { "mentions": false } });
+        let defaults = json!({ "respond_to": { "mentions": true, "keywords": ["foo"] } });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let rt = resolved.respond_to.unwrap();
+        assert!(!rt.mentions);
+        assert!(rt.keywords.is_empty(), "pack keywords should be lost under shallow replacement");
+        assert!(!rt.all_messages); // built-in default
+    }
+
+    #[test]
+    fn respond_to_empty_object_gets_builtin_defaults() {
+        // S1: Persona sets `respond_to: {}` — present but empty.
+        // All sub-fields fall to built-in defaults.
+        let persona = json!({ "respond_to": {} });
+        let defaults = json!({ "respond_to": { "mentions": false, "keywords": ["security"] } });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let rt = resolved.respond_to.unwrap();
+        assert!(rt.mentions, "built-in default for mentions is true");
+        assert!(rt.keywords.is_empty(), "built-in default for keywords is empty");
+        assert!(!rt.all_messages, "built-in default for all_messages is false");
+    }
+
+    #[test]
+    fn respond_to_absent_inherits_pack_default() {
+        // S1: Persona has no respond_to — fall through to pack default.
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({ "respond_to": { "mentions": false, "keywords": ["deploy"] } });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let rt = resolved.respond_to.unwrap();
+        assert!(!rt.mentions);
+        assert_eq!(rt.keywords, vec!["deploy"]);
+    }
+
+    #[test]
+    fn respond_to_null_inherits_pack_default() {
+        // S1: Persona explicitly sets respond_to: null — fall through to pack default.
+        let persona = json!({ "respond_to": null });
+        let defaults = json!({ "respond_to": { "all_messages": true } });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let rt = resolved.respond_to.unwrap();
+        assert!(rt.all_messages);
+    }
+
+    #[test]
+    fn respond_to_persona_explicit_empty_keywords_overrides_pack() {
+        // Persona explicitly sets `keywords: []`; pack default has `keywords: ["foo"]`.
+        // Under shallow replacement, persona wins entirely — pack is ignored.
+        let persona = json!({ "respond_to": { "keywords": [] } });
+        let defaults = json!({ "respond_to": { "keywords": ["foo"] } });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let rt = resolved.respond_to.unwrap();
+        assert!(rt.keywords.is_empty());
+    }
+
+    #[test]
+    fn respond_to_neither_side_returns_none() {
+        // Neither persona nor pack has respond_to — result is None.
+        let persona = json!({ "model": "gpt-4o" });
+        let resolved = resolve_persona_config(&persona, None);
+        assert!(resolved.respond_to.is_none());
+    }
+
+    // ── subscribe merge (S2) ─────────────────────────────────────────────────
+
+    #[test]
+    fn subscribe_null_falls_through_to_pack_default() {
+        // S2: persona subscribe: null → falls through to pack default.
+        let persona = json!({ "subscribe": null });
+        let defaults = json!({ "subscribe": ["#general", "#alerts"] });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        assert_eq!(resolved.subscribe, vec!["#general", "#alerts"]);
+    }
+
+    #[test]
+    fn subscribe_empty_overrides_pack_default() {
+        // S2: persona subscribe: [] → intentional "subscribe to nothing".
+        let persona = json!({ "subscribe": [] });
+        let defaults = json!({ "subscribe": ["#general"] });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        assert!(resolved.subscribe.is_empty());
+    }
+
+    #[test]
+    fn subscribe_absent_falls_through_to_pack_default() {
+        // S2: persona has no subscribe field → falls through to pack default.
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({ "subscribe": ["#security-reviews"] });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        assert_eq!(resolved.subscribe, vec!["#security-reviews"]);
+    }
+}

From 91e5dfe377ffa15c0d84988cd0f94d3e71234d19 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:22:32 -0400
Subject: [PATCH 02/28] feat(cli): add 'sprout pack validate' and 'sprout pack
 inspect' commands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add persona pack management subcommands to the CLI:

- sprout pack validate <path> — validates a pack directory, prints
  diagnostics, exits 0 (valid), 1 (errors found)
- sprout pack inspect <path> — loads a pack and pretty-prints metadata
  and effective per-persona config (model, temperature, subscribe, etc.)

Both commands are local-only (no relay connection needed) and are handled
before SproutClient creation, like the auth command.

Changes:
- Cargo.toml: add sprout-persona dependency
- main.rs: add Pack(PackCmd) subcommand with Validate/Inspect variants
- commands/pack.rs: new file with cmd_validate and cmd_inspect
- commands/mod.rs: register pack module
- Update command inventory test: 53 → 54
---
 crates/sprout-cli/Cargo.toml           |   3 +
 crates/sprout-cli/src/commands/mod.rs  |   1 +
 crates/sprout-cli/src/commands/pack.rs | 151 +++++++++++++++++++++++++
 crates/sprout-cli/src/main.rs          |  40 ++++++-
 4 files changed, 191 insertions(+), 4 deletions(-)
 create mode 100644 crates/sprout-cli/src/commands/pack.rs

diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml
index 0d02070eab..7668f1e030 100644
--- a/crates/sprout-cli/Cargo.toml
+++ b/crates/sprout-cli/Cargo.toml
@@ -42,3 +42,6 @@ base64 = "0.22"
 
 # SHA-256 — NIP-98 payload hash tag
 sha2 = "0.11"
+
+# Persona pack parsing, validation, and resolution
+sprout-persona = { path = "../sprout-persona" }
diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs
index b5167e7950..a3dc7523de 100644
--- a/crates/sprout-cli/src/commands/mod.rs
+++ b/crates/sprout-cli/src/commands/mod.rs
@@ -3,6 +3,7 @@ pub mod channels;
 pub mod dms;
 pub mod feed;
 pub mod messages;
+pub mod pack;
 pub mod reactions;
 pub mod social;
 pub mod users;
diff --git a/crates/sprout-cli/src/commands/pack.rs b/crates/sprout-cli/src/commands/pack.rs
new file mode 100644
index 0000000000..6518c3e7e8
--- /dev/null
+++ b/crates/sprout-cli/src/commands/pack.rs
@@ -0,0 +1,151 @@
+//! `sprout pack` subcommands — local persona pack operations.
+//!
+//! These commands operate on local pack directories. No relay connection needed.
+
+use std::path::Path;
+use std::process;
+
+use crate::error::CliError;
+
+/// Run `sprout pack validate <path>`.
+///
+/// Calls `validate_pack()` from the persona crate, prints diagnostics,
+/// and exits with the appropriate code:
+/// - 0: valid (may have warnings)
+/// - 1: errors found
+pub fn cmd_validate(path: &str) -> Result<(), CliError> {
+    let pack_dir = Path::new(path);
+    if !pack_dir.exists() {
+        eprintln!("error: path does not exist: {path}");
+        process::exit(1);
+    }
+    if !pack_dir.is_dir() {
+        eprintln!("error: not a directory: {path}");
+        process::exit(1);
+    }
+
+    let report = sprout_persona::validate::validate_pack(pack_dir);
+
+    for diag in &report.diagnostics {
+        match diag {
+            sprout_persona::validate::ValidationDiagnostic::Error(msg) => {
+                eprintln!("  ERROR: {msg}");
+            }
+            sprout_persona::validate::ValidationDiagnostic::Warning(msg) => {
+                eprintln!("  WARN:  {msg}");
+            }
+        }
+    }
+
+    if report.has_errors() {
+        eprintln!("\nValidation failed.");
+        process::exit(1);
+    } else if report.has_warnings() {
+        println!("Valid (with warnings).");
+    } else {
+        println!("Valid.");
+    }
+
+    Ok(())
+}
+
+/// Run `sprout pack inspect <path>`.
+///
+/// Loads and resolves a pack, then pretty-prints a summary of each persona's
+/// effective configuration.
+pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
+    let pack_dir = Path::new(path);
+    if !pack_dir.exists() {
+        eprintln!("error: path does not exist: {path}");
+        process::exit(1);
+    }
+    if !pack_dir.is_dir() {
+        eprintln!("error: not a directory: {path}");
+        process::exit(1);
+    }
+
+    // Load the pack (validate + load in one step).
+    let pack = sprout_persona::pack::load_pack(pack_dir)
+        .map_err(|e| CliError::Other(format!("failed to load pack: {e}")))?;
+
+    // Header
+    println!("Pack: {} ({})", pack.manifest.name, pack.manifest.id);
+    println!("Version: {}", pack.manifest.version);
+    println!("Personas: {}", pack.personas.len());
+    if pack.pack_instructions.is_some() {
+        println!("Pack instructions: yes");
+    }
+    if pack.shared_mcp_config.is_some() {
+        println!("Shared MCP config: yes");
+    }
+    if pack.skills_dir.is_some() {
+        println!("Skills directory: yes");
+    }
+    println!();
+
+    // Per-persona summary
+    for persona in &pack.personas {
+        println!("  {}", persona.name);
+        println!("    Display: {}", persona.display_name);
+        println!("    Description: {}", persona.description);
+
+        if let Some(ref model) = persona.model {
+            println!("    Model: {model}");
+        }
+        if let Some(temp) = persona.temperature {
+            println!("    Temperature: {temp}");
+        }
+        if let Some(ctx) = persona.max_context_tokens {
+            println!("    Max context tokens: {ctx}");
+        }
+
+        if !persona.subscribe.is_empty() {
+            println!("    Subscribe: {}", persona.subscribe.join(", "));
+        }
+
+        if let Some(ref rt) = persona.respond_to {
+            let mut parts = Vec::new();
+            if rt.mentions {
+                parts.push("mentions".to_string());
+            }
+            if !rt.keywords.is_empty() {
+                parts.push(format!("keywords {:?}", rt.keywords));
+            }
+            if rt.all_messages {
+                parts.push("all_messages".to_string());
+            }
+            if !parts.is_empty() {
+                println!("    Respond to: {}", parts.join(" + "));
+            }
+        }
+
+        println!("    Thread replies: {}", persona.thread_replies);
+        println!("    Broadcast replies: {}", persona.broadcast_replies);
+
+        if !persona.mcp_servers.is_empty() {
+            println!("    MCP servers: {}", persona.mcp_servers.len());
+        }
+
+        if !persona.skills.is_empty() {
+            println!("    Skills: {}", persona.skills.join(", "));
+        }
+
+        if let Some(ref avatar) = persona.avatar {
+            println!("    Avatar: {avatar}");
+        }
+
+        let prompt_preview = if persona.prompt.len() > 80 {
+            format!("{}...", &persona.prompt[..77])
+        } else {
+            persona.prompt.clone()
+        };
+        println!(
+            "    Prompt: {} chars ({})",
+            persona.prompt.len(),
+            prompt_preview.replace('\n', " ")
+        );
+        println!();
+    }
+
+    Ok(())
+}
diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs
index 120b8dc025..03279d6bd4 100644
--- a/crates/sprout-cli/src/main.rs
+++ b/crates/sprout-cli/src/main.rs
@@ -453,6 +453,26 @@ enum Cmd {
         #[arg(long)]
         pubkey: String,
     },
+
+    // ---- Pack (local) ------------------------------------------------------
+    /// Persona pack operations (local, no relay connection needed)
+    #[command(subcommand)]
+    Pack(PackCmd),
+}
+
+/// Subcommands for `sprout pack`.
+#[derive(Subcommand)]
+enum PackCmd {
+    /// Validate a persona pack directory
+    Validate {
+        /// Path to the pack directory
+        path: String,
+    },
+    /// Inspect a persona pack — show metadata and effective config
+    Inspect {
+        /// Path to the pack directory
+        path: String,
+    },
 }
 
 // ---------------------------------------------------------------------------
@@ -503,6 +523,14 @@ async fn run(cli: Cli) -> Result<(), CliError> {
         return commands::auth::cmd_auth(&relay_url, cli.private_key.as_deref()).await;
     }
 
+    // Pack commands are local-only — no relay connection needed.
+    if let Cmd::Pack(ref sub) = cli.command {
+        return match sub {
+            PackCmd::Validate { path } => commands::pack::cmd_validate(path),
+            PackCmd::Inspect { path } => commands::pack::cmd_inspect(path),
+        };
+    }
+
     // Auth resolution: token > private_key (auto-mint) > pubkey > error
     //
     // When SPROUT_PRIVATE_KEY is set, auto_mint_token returns (token, keys).
@@ -811,6 +839,9 @@ async fn run(cli: Cli) -> Result<(), CliError> {
             commands::social::cmd_get_contact_list(&client, &pubkey).await
         }
 
+        // ---- Pack (local) --------------------------------------------------
+        Cmd::Pack(_) => unreachable!("handled above"),
+
         // ---- Auth & Tokens -------------------------------------------------
         Cmd::Auth => unreachable!("handled above"),
         Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await,
@@ -862,10 +893,10 @@ mod tests {
         assert!(super::parse_bool_flag("--approved", "").is_err());
     }
 
-    /// Parity: the CLI exposes exactly the expected 53 commands.
+    /// Parity: the CLI exposes exactly the expected 54 commands.
     /// If a command is added or removed, this test forces a conscious update.
     #[test]
-    fn command_inventory_is_53() {
+    fn command_inventory_is_54() {
         let expected: Vec<&str> = vec![
             "add-channel-member",
             "add-dm-member",
@@ -902,6 +933,7 @@ mod tests {
             "list-tokens",
             "list-workflows",
             "open-dm",
+            "pack",
             "publish-note",
             "remove-channel-member",
             "remove-reaction",
@@ -932,8 +964,8 @@ mod tests {
 
         assert_eq!(
             actual.len(),
-            53,
-            "Expected 53 commands, got {}. Actual: {:?}",
+            54,
+            "Expected 54 commands, got {}. Actual: {:?}",
             actual.len(),
             actual
         );

From 43d6ab869c9de15d7ea03b671485c49cda41d6be Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:23:26 -0400
Subject: [PATCH 03/28] =?UTF-8?q?feat(persona):=20add=20resolve.rs=20?=
 =?UTF-8?q?=E2=80=94=20ResolvedPack=20API=20for=20ACP=20consumption?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The key deliverable: resolve_pack() loads a pack directory and produces
a fully typed, ACP-ready ResolvedPack with ResolvedPersona entries.

All merge policy (levels 3-5) is applied. Features:
- System prompt composition (persona body + pack instructions)
- Model string splitting (provider:model-id → ResolvedModel)
- MCP server merge (pack shared + persona-specific, persona wins)
- Env var literals preserved (no interpolation — deferred)
- Goose env var projection (GOOSE_PROVIDER, GOOSE_MODEL, etc.)
- Hooks resolution (pack-relative → absolute paths)
- Triggers (renamed from respond_to per spec discussion)

Design: pure functions, no env access, no network. ACP owns the
security boundary for env filtering and interpolation.

28 new tests (unit + filesystem integration). 144 total pass.
---
 crates/sprout-persona/src/lib.rs     |   7 +
 crates/sprout-persona/src/resolve.rs | 751 +++++++++++++++++++++++++++
 2 files changed, 758 insertions(+)
 create mode 100644 crates/sprout-persona/src/lib.rs
 create mode 100644 crates/sprout-persona/src/resolve.rs

diff --git a/crates/sprout-persona/src/lib.rs b/crates/sprout-persona/src/lib.rs
new file mode 100644
index 0000000000..cacef6df4d
--- /dev/null
+++ b/crates/sprout-persona/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod legacy;
+pub mod manifest;
+pub mod merge;
+pub mod pack;
+pub mod persona;
+pub mod resolve;
+pub mod validate;
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
new file mode 100644
index 0000000000..1faa2da180
--- /dev/null
+++ b/crates/sprout-persona/src/resolve.rs
@@ -0,0 +1,751 @@
+//! Pack resolution: produces fully resolved, ACP-ready output.
+//!
+//! `resolve_pack()` is the main entry point. It loads a pack directory,
+//! applies merge policy, composes prompts, merges MCP servers, and projects
+//! env vars. The output (`ResolvedPack`) is designed backward from ACP's
+//! `Config` — every field maps directly to what the runtime consumes.
+//!
+//! Design principles:
+//! - **Pure**: no env access, no network, no side effects.
+//! - **Complete**: all merge/compose/project logic lives here.
+//! - **ACP-shaped**: `ResolvedPersona` maps 1:1 to ACP's needs.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use crate::merge::RespondToData;
+use crate::pack::{self, LoadedPack, LoadedPersona, PackError};
+use crate::persona::split_model;
+
+// ── Public types ──────────────────────────────────────────────────────────────
+
+/// A fully resolved persona — ready for ACP consumption.
+/// All merge, composition, and projection is done.
+#[derive(Debug, Clone)]
+pub struct ResolvedPersona {
+    // Identity
+    pub name: String,
+    pub display_name: String,
+    pub description: String,
+    pub avatar: Option<String>,
+    pub version: String,
+
+    // Effective prompt (persona body + pack_instructions appended)
+    pub system_prompt: String,
+
+    // Effective behavior (all precedence levels 3-5 resolved)
+    pub model: Option<ResolvedModel>,
+    pub temperature: Option<f64>,
+    pub max_context_tokens: Option<u64>,
+    pub subscribe: Vec<String>,
+    pub triggers: ResolvedTriggers,
+    pub thread_replies: bool,
+    pub broadcast_replies: bool,
+
+    // Effective MCP (pack shared + persona merged, literals preserved)
+    pub mcp_servers: Vec<ResolvedMcpServer>,
+
+    // Hooks (parsed, not executed)
+    pub hooks: Option<ResolvedHooks>,
+
+    // Skills (bare names)
+    pub skills: Vec<String>,
+
+    // Env var projection for goose subprocess
+    pub goose_env_vars: Vec<(String, String)>,
+}
+
+/// Provider + model ID, split from `"provider:model-id"`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedModel {
+    pub provider: String,
+    pub model_id: String,
+}
+
+/// An MCP server with env values as literals (no interpolation in this PR).
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedMcpServer {
+    pub name: String,
+    pub command: String,
+    pub args: Vec<String>,
+    pub env: Vec<(String, String)>,
+}
+
+/// Lifecycle hooks (pack-relative paths).
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedHooks {
+    pub on_start: Option<PathBuf>,
+    pub on_stop: Option<PathBuf>,
+}
+
+/// What triggers a response (renamed from respond_to per spec discussion).
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedTriggers {
+    pub mentions: bool,
+    pub keywords: Vec<String>,
+    pub all_messages: bool,
+}
+
+/// A fully resolved pack.
+#[derive(Debug)]
+pub struct ResolvedPack {
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    pub personas: Vec<ResolvedPersona>,
+}
+
+// ── Entry point ───────────────────────────────────────────────────────────────
+
+/// Load, validate, merge, and resolve a pack directory.
+///
+/// Returns a `ResolvedPack` with fully typed, ACP-ready output for each
+/// persona. All merge policy (levels 3-5) is applied. MCP servers are
+/// merged with literal env passthrough (no `${VAR}` interpolation).
+/// Goose env vars are projected from model/temperature/context config.
+pub fn resolve_pack(pack_dir: &Path) -> Result<ResolvedPack, PackError> {
+    let loaded = pack::load_pack(pack_dir)?;
+    resolve_loaded_pack(&loaded, pack_dir)
+}
+
+/// Resolve from an already-loaded pack. Useful when you've already called
+/// `load_pack()` and want to avoid re-reading the filesystem.
+pub fn resolve_loaded_pack(
+    loaded: &LoadedPack,
+    pack_dir: &Path,
+) -> Result<ResolvedPack, PackError> {
+    let pack_version = &loaded.manifest.version;
+    let pack_instructions = loaded.pack_instructions.as_deref();
+    let shared_mcp = loaded.shared_mcp_config.as_ref();
+
+    let mut personas = Vec::with_capacity(loaded.personas.len());
+    for lp in &loaded.personas {
+        personas.push(resolve_persona(lp, pack_version, pack_instructions, shared_mcp, pack_dir));
+    }
+
+    Ok(ResolvedPack {
+        id: loaded.manifest.id.clone(),
+        name: loaded.manifest.name.clone(),
+        version: loaded.manifest.version.clone(),
+        // PackManifestData doesn't carry description; use pack name as fallback.
+        description: String::new(),
+        personas,
+    })
+}
+
+// ── Per-persona resolution ────────────────────────────────────────────────────
+
+fn resolve_persona(
+    lp: &LoadedPersona,
+    pack_version: &str,
+    pack_instructions: Option<&str>,
+    shared_mcp: Option<&serde_json::Value>,
+    pack_dir: &Path,
+) -> ResolvedPersona {
+    let system_prompt = compose_prompt(&lp.prompt, pack_instructions);
+    let model = lp.model.as_deref().and_then(resolve_model);
+    let triggers = resolve_triggers(lp.respond_to.as_ref());
+    let mcp_servers = merge_mcp_servers(shared_mcp, &lp.mcp_servers);
+    let hooks = resolve_hooks(lp.hooks.as_ref(), pack_dir);
+    let goose_env_vars = project_env_vars(lp);
+
+    // Version: persona version if set, otherwise pack version.
+    let version = lp
+        .source_path
+        .to_str()
+        .and_then(|_| None::<String>) // persona doesn't store version directly
+        .unwrap_or_else(|| pack_version.to_owned());
+
+    ResolvedPersona {
+        name: lp.name.clone(),
+        display_name: lp.display_name.clone(),
+        description: lp.description.clone(),
+        avatar: lp.avatar.clone(),
+        version,
+        system_prompt,
+        model,
+        temperature: lp.temperature,
+        max_context_tokens: lp.max_context_tokens,
+        subscribe: lp.subscribe.clone(),
+        triggers,
+        thread_replies: lp.thread_replies,
+        broadcast_replies: lp.broadcast_replies,
+        mcp_servers,
+        hooks,
+        skills: lp.skills.clone(),
+        goose_env_vars,
+    }
+}
+
+// ── Compose prompt ────────────────────────────────────────────────────────────
+
+/// Compose the effective system prompt: persona body + pack instructions.
+fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> String {
+    match pack_instructions {
+        Some(instructions) if !instructions.trim().is_empty() => {
+            format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}")
+        }
+        _ => persona_prompt.to_owned(),
+    }
+}
+
+// ── Model resolution ──────────────────────────────────────────────────────────
+
+/// Split `"provider:model-id"` into a `ResolvedModel`.
+fn resolve_model(model_str: &str) -> Option<ResolvedModel> {
+    if model_str.trim().is_empty() {
+        return None;
+    }
+    let (provider, model_id) = split_model(model_str);
+    Some(ResolvedModel {
+        provider: provider.unwrap_or("").to_owned(),
+        model_id: model_id.to_owned(),
+    })
+}
+
+// ── Triggers resolution ───────────────────────────────────────────────────────
+
+/// Convert `RespondToData` to `ResolvedTriggers` (renamed per spec discussion).
+fn resolve_triggers(rt: Option<&RespondToData>) -> ResolvedTriggers {
+    match rt {
+        Some(data) => ResolvedTriggers {
+            mentions: data.mentions,
+            keywords: data.keywords.clone(),
+            all_messages: data.all_messages,
+        },
+        None => ResolvedTriggers {
+            mentions: true,
+            keywords: Vec::new(),
+            all_messages: false,
+        },
+    }
+}
+
+// ── MCP server merge ──────────────────────────────────────────────────────────
+
+/// Merge pack-level shared MCP servers with per-persona servers.
+///
+/// Pack shared servers come from `.mcp.json` (a map of `name → config`).
+/// Per-persona servers come from frontmatter `mcp_servers:` (a list).
+/// Name collision: persona wins (replaces pack server with same name).
+///
+/// Env values are passed through as literals — no `${VAR}` interpolation.
+fn merge_mcp_servers(
+    shared_mcp: Option<&serde_json::Value>,
+    persona_servers: &[serde_json::Value],
+) -> Vec<ResolvedMcpServer> {
+    let mut by_name: HashMap<String, ResolvedMcpServer> = HashMap::new();
+
+    // 1. Pack-level shared servers from .mcp.json
+    if let Some(shared) = shared_mcp {
+        // .mcp.json format: { "mcpServers": { "name": { "command": ..., "args": [...], "env": {...} } } }
+        if let Some(servers_obj) = shared
+            .get("mcpServers")
+            .and_then(|v| v.as_object())
+        {
+            for (name, config) in servers_obj {
+                if let Some(server) = parse_mcp_server_config(name, config) {
+                    by_name.insert(name.clone(), server);
+                }
+            }
+        }
+    }
+
+    // 2. Per-persona servers (persona wins on name collision)
+    for server_val in persona_servers {
+        if let Some(name) = server_val.get("name").and_then(|v| v.as_str()) {
+            if let Some(server) = parse_mcp_server_config(name, server_val) {
+                by_name.insert(name.to_owned(), server);
+            }
+        }
+    }
+
+    // Return in deterministic order (sorted by name)
+    let mut servers: Vec<_> = by_name.into_values().collect();
+    servers.sort_by(|a, b| a.name.cmp(&b.name));
+    servers
+}
+
+/// Parse a single MCP server config from JSON.
+fn parse_mcp_server_config(name: &str, config: &serde_json::Value) -> Option<ResolvedMcpServer> {
+    let command = config.get("command").and_then(|v| v.as_str())?.to_owned();
+    let args = config
+        .get("args")
+        .and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|v| v.as_str().map(str::to_owned))
+                .collect()
+        })
+        .unwrap_or_default();
+    let env = config
+        .get("env")
+        .and_then(|v| v.as_object())
+        .map(|obj| {
+            obj.iter()
+                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
+                .collect()
+        })
+        .unwrap_or_default();
+
+    Some(ResolvedMcpServer {
+        name: name.to_owned(),
+        command,
+        args,
+        env,
+    })
+}
+
+// ── Hooks resolution ──────────────────────────────────────────────────────────
+
+/// Resolve hooks to absolute paths (pack-relative → absolute).
+fn resolve_hooks(
+    hooks: Option<&crate::merge::HooksData>,
+    pack_dir: &Path,
+) -> Option<ResolvedHooks> {
+    let h = hooks?;
+    // Only return Some if at least one hook is set.
+    if h.on_start.is_none() && h.on_stop.is_none() {
+        return None;
+    }
+    Some(ResolvedHooks {
+        on_start: h.on_start.as_ref().map(|p| pack_dir.join(p)),
+        on_stop: h.on_stop.as_ref().map(|p| pack_dir.join(p)),
+    })
+}
+
+// ── Env var projection ────────────────────────────────────────────────────────
+
+/// Project persona config into goose subprocess env vars.
+///
+/// Pure function — does NOT read the current process env.
+/// ACP is responsible for filtering based on operator precedence (level 1).
+fn project_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
+    let mut vars = Vec::new();
+
+    if let Some(model_str) = &persona.model {
+        let (provider, model_id) = split_model(model_str);
+        if let Some(p) = provider {
+            vars.push(("GOOSE_PROVIDER".to_owned(), p.to_owned()));
+        }
+        vars.push(("GOOSE_MODEL".to_owned(), model_id.to_owned()));
+    }
+
+    if let Some(temp) = persona.temperature {
+        vars.push(("GOOSE_TEMPERATURE".to_owned(), temp.to_string()));
+    }
+
+    if let Some(ctx) = persona.max_context_tokens {
+        vars.push(("GOOSE_CONTEXT_LIMIT".to_owned(), ctx.to_string()));
+    }
+
+    vars
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::merge::HooksData;
+
+    // ── compose_prompt ────────────────────────────────────────────────────
+
+    #[test]
+    fn compose_prompt_body_only() {
+        let result = compose_prompt("You are a bot.", None);
+        assert_eq!(result, "You are a bot.");
+    }
+
+    #[test]
+    fn compose_prompt_with_instructions() {
+        let result = compose_prompt("You are a bot.", Some("Follow the rules."));
+        assert!(result.starts_with("You are a bot."));
+        assert!(result.contains("# Team Instructions"));
+        assert!(result.contains("Follow the rules."));
+    }
+
+    #[test]
+    fn compose_prompt_empty_instructions_ignored() {
+        let result = compose_prompt("You are a bot.", Some("   "));
+        assert_eq!(result, "You are a bot.");
+    }
+
+    // ── resolve_model ─────────────────────────────────────────────────────
+
+    #[test]
+    fn resolve_model_with_provider() {
+        let m = resolve_model("anthropic:claude-sonnet-4-20250514").unwrap();
+        assert_eq!(m.provider, "anthropic");
+        assert_eq!(m.model_id, "claude-sonnet-4-20250514");
+    }
+
+    #[test]
+    fn resolve_model_without_provider() {
+        let m = resolve_model("gpt-4o").unwrap();
+        assert_eq!(m.provider, "");
+        assert_eq!(m.model_id, "gpt-4o");
+    }
+
+    #[test]
+    fn resolve_model_empty_returns_none() {
+        assert!(resolve_model("").is_none());
+        assert!(resolve_model("  ").is_none());
+    }
+
+    // ── resolve_triggers ──────────────────────────────────────────────────
+
+    #[test]
+    fn triggers_from_respond_to_data() {
+        let data = RespondToData {
+            mentions: false,
+            keywords: vec!["security".into(), "CVE".into()],
+            all_messages: false,
+        };
+        let t = resolve_triggers(Some(&data));
+        assert!(!t.mentions);
+        assert_eq!(t.keywords, vec!["security", "CVE"]);
+        assert!(!t.all_messages);
+    }
+
+    #[test]
+    fn triggers_default_when_none() {
+        let t = resolve_triggers(None);
+        assert!(t.mentions);
+        assert!(t.keywords.is_empty());
+        assert!(!t.all_messages);
+    }
+
+    // ── merge_mcp_servers ─────────────────────────────────────────────────
+
+    #[test]
+    fn mcp_merge_shared_only() {
+        let shared = serde_json::json!({
+            "mcpServers": {
+                "sprout-mcp": {
+                    "command": "npx",
+                    "args": ["-y", "sprout-mcp"],
+                    "env": { "TOKEN": "abc" }
+                }
+            }
+        });
+        let result = merge_mcp_servers(Some(&shared), &[]);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].name, "sprout-mcp");
+        assert_eq!(result[0].command, "npx");
+        assert_eq!(result[0].env, vec![("TOKEN".into(), "abc".into())]);
+    }
+
+    #[test]
+    fn mcp_merge_persona_wins_on_collision() {
+        let shared = serde_json::json!({
+            "mcpServers": {
+                "my-server": {
+                    "command": "old-cmd",
+                    "args": [],
+                    "env": {}
+                }
+            }
+        });
+        let persona = vec![serde_json::json!({
+            "name": "my-server",
+            "command": "new-cmd",
+            "args": ["--flag"],
+            "env": { "KEY": "val" }
+        })];
+        let result = merge_mcp_servers(Some(&shared), &persona);
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].command, "new-cmd");
+        assert_eq!(result[0].args, vec!["--flag"]);
+    }
+
+    #[test]
+    fn mcp_merge_both_sources_combined() {
+        let shared = serde_json::json!({
+            "mcpServers": {
+                "alpha": { "command": "alpha-cmd" }
+            }
+        });
+        let persona = vec![serde_json::json!({
+            "name": "beta",
+            "command": "beta-cmd"
+        })];
+        let result = merge_mcp_servers(Some(&shared), &persona);
+        assert_eq!(result.len(), 2);
+        // Sorted by name
+        assert_eq!(result[0].name, "alpha");
+        assert_eq!(result[1].name, "beta");
+    }
+
+    #[test]
+    fn mcp_merge_no_shared_no_persona() {
+        let result = merge_mcp_servers(None, &[]);
+        assert!(result.is_empty());
+    }
+
+    #[test]
+    fn mcp_env_literals_preserved() {
+        // ${VAR_NAME} should pass through as-is (no interpolation in this PR)
+        let persona = vec![serde_json::json!({
+            "name": "test",
+            "command": "cmd",
+            "env": { "PATH": "${HOME}/bin", "SECRET": "${MY_SECRET}" }
+        })];
+        let result = merge_mcp_servers(None, &persona);
+        assert_eq!(result.len(), 1);
+        let env: HashMap<String, String> = result[0].env.iter().cloned().collect();
+        assert_eq!(env["PATH"], "${HOME}/bin");
+        assert_eq!(env["SECRET"], "${MY_SECRET}");
+    }
+
+    // ── resolve_hooks ─────────────────────────────────────────────────────
+
+    #[test]
+    fn hooks_resolved_to_absolute_paths() {
+        let data = HooksData {
+            on_start: Some("hooks/start.sh".into()),
+            on_stop: Some("hooks/stop.sh".into()),
+            on_message: None,
+        };
+        let pack_dir = Path::new("/packs/my-pack");
+        let h = resolve_hooks(Some(&data), pack_dir).unwrap();
+        assert_eq!(h.on_start.unwrap(), PathBuf::from("/packs/my-pack/hooks/start.sh"));
+        assert_eq!(h.on_stop.unwrap(), PathBuf::from("/packs/my-pack/hooks/stop.sh"));
+    }
+
+    #[test]
+    fn hooks_none_when_empty() {
+        let data = HooksData {
+            on_start: None,
+            on_stop: None,
+            on_message: None,
+        };
+        assert!(resolve_hooks(Some(&data), Path::new("/x")).is_none());
+    }
+
+    #[test]
+    fn hooks_none_when_absent() {
+        assert!(resolve_hooks(None, Path::new("/x")).is_none());
+    }
+
+    // ── project_env_vars ──────────────────────────────────────────────────
+
+    #[test]
+    fn env_vars_projected_from_model() {
+        let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None);
+        let vars = project_env_vars(&lp);
+        let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
+        assert_eq!(map["GOOSE_PROVIDER"], "anthropic");
+        assert_eq!(map["GOOSE_MODEL"], "claude-sonnet-4-20250514");
+    }
+
+    #[test]
+    fn env_vars_model_without_provider() {
+        let lp = stub_persona(Some("gpt-4o"), None, None);
+        let vars = project_env_vars(&lp);
+        let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
+        assert!(!map.contains_key("GOOSE_PROVIDER"));
+        assert_eq!(map["GOOSE_MODEL"], "gpt-4o");
+    }
+
+    #[test]
+    fn env_vars_temperature_and_context() {
+        let lp = stub_persona(None, Some(0.7), Some(8192));
+        let vars = project_env_vars(&lp);
+        let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
+        assert_eq!(map["GOOSE_TEMPERATURE"], "0.7");
+        assert_eq!(map["GOOSE_CONTEXT_LIMIT"], "8192");
+    }
+
+    #[test]
+    fn env_vars_empty_when_no_config() {
+        let lp = stub_persona(None, None, None);
+        let vars = project_env_vars(&lp);
+        assert!(vars.is_empty());
+    }
+
+    #[test]
+    fn env_vars_full_projection() {
+        let lp = stub_persona(Some("openai:gpt-4o"), Some(0.5), Some(16384));
+        let vars = project_env_vars(&lp);
+        assert_eq!(vars.len(), 4); // PROVIDER, MODEL, TEMPERATURE, CONTEXT_LIMIT
+    }
+
+    // ── Full pipeline (resolve_pack via filesystem) ───────────────────────
+
+    #[test]
+    fn resolve_minimal_pack() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.minimal",
+                "name": "Minimal Pack",
+                "version": "0.1.0",
+                "personas": ["agents/bot.persona.md"]
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/bot.persona.md"),
+            "---\nname: bot\ndisplay_name: Bot\ndescription: A test bot.\n---\nYou are Bot.\n",
+        )
+        .unwrap();
+
+        let pack = resolve_pack(dir).unwrap();
+        assert_eq!(pack.id, "com.test.minimal");
+        assert_eq!(pack.personas.len(), 1);
+
+        let p = &pack.personas[0];
+        assert_eq!(p.name, "bot");
+        assert_eq!(p.system_prompt, "You are Bot.\n");
+        assert!(p.model.is_none());
+        assert!(p.triggers.mentions); // built-in default
+        assert!(p.mcp_servers.is_empty());
+        assert!(p.goose_env_vars.is_empty());
+    }
+
+    #[test]
+    fn resolve_pack_with_instructions() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.instructions",
+                "name": "Instructions Pack",
+                "version": "1.0.0",
+                "personas": ["agents/bot.persona.md"],
+                "pack_instructions": "instructions.md"
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(dir.join("instructions.md"), "Always be helpful.").unwrap();
+
+        std::fs::write(
+            dir.join("agents/bot.persona.md"),
+            "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nmodel: anthropic:claude-sonnet-4-20250514\n---\nYou are Bot.\n",
+        )
+        .unwrap();
+
+        let pack = resolve_pack(dir).unwrap();
+        let p = &pack.personas[0];
+
+        // Prompt composed with instructions
+        assert!(p.system_prompt.contains("You are Bot."));
+        assert!(p.system_prompt.contains("# Team Instructions"));
+        assert!(p.system_prompt.contains("Always be helpful."));
+
+        // Model resolved
+        let m = p.model.as_ref().unwrap();
+        assert_eq!(m.provider, "anthropic");
+        assert_eq!(m.model_id, "claude-sonnet-4-20250514");
+
+        // Env vars projected
+        let env_map: HashMap<&str, &str> = p
+            .goose_env_vars
+            .iter()
+            .map(|(k, v)| (k.as_str(), v.as_str()))
+            .collect();
+        assert_eq!(env_map["GOOSE_PROVIDER"], "anthropic");
+        assert_eq!(env_map["GOOSE_MODEL"], "claude-sonnet-4-20250514");
+    }
+
+    #[test]
+    fn resolve_multi_persona_pack() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.multi",
+                "name": "Multi Pack",
+                "version": "2.0.0",
+                "personas": ["agents/pip.persona.md", "agents/lep.persona.md"],
+                "defaults": {
+                    "model": "anthropic:claude-sonnet-4-20250514",
+                    "temperature": 0.7,
+                    "thread_replies": true
+                }
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/pip.persona.md"),
+            "---\nname: pip\ndisplay_name: Pip\ndescription: The lead.\nmodel: anthropic:claude-4-opus-20250514\nsubscribe:\n  - '#reviews'\n---\nYou are Pip.\n",
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/lep.persona.md"),
+            "---\nname: lep\ndisplay_name: Lep\ndescription: The analyst.\ntemperature: 0.3\n---\nYou are Lep.\n",
+        )
+        .unwrap();
+
+        let pack = resolve_pack(dir).unwrap();
+        assert_eq!(pack.personas.len(), 2);
+
+        let pip = pack.personas.iter().find(|p| p.name == "pip").unwrap();
+        let lep = pack.personas.iter().find(|p| p.name == "lep").unwrap();
+
+        // pip overrides model
+        assert_eq!(pip.model.as_ref().unwrap().model_id, "claude-4-opus-20250514");
+        // lep inherits model from defaults
+        assert_eq!(lep.model.as_ref().unwrap().model_id, "claude-sonnet-4-20250514");
+
+        // pip inherits temperature from defaults
+        assert_eq!(pip.temperature, Some(0.7));
+        // lep overrides temperature
+        assert_eq!(lep.temperature, Some(0.3));
+
+        // pip has explicit subscribe
+        assert_eq!(pip.subscribe, vec!["#reviews"]);
+        // lep has no subscribe (empty from defaults)
+        assert!(lep.subscribe.is_empty());
+
+        // Both inherit thread_replies from defaults
+        assert!(pip.thread_replies);
+        assert!(lep.thread_replies);
+    }
+
+    // ── Test helpers ──────────────────────────────────────────────────────
+
+    fn stub_persona(
+        model: Option<&str>,
+        temperature: Option<f64>,
+        max_context_tokens: Option<u64>,
+    ) -> LoadedPersona {
+        LoadedPersona {
+            source_path: PathBuf::from("test.persona.md"),
+            name: "test".into(),
+            display_name: "Test".into(),
+            description: "A test persona.".into(),
+            avatar: None,
+            model: model.map(str::to_owned),
+            temperature,
+            max_context_tokens,
+            subscribe: vec![],
+            respond_to: None,
+            thread_replies: true,
+            broadcast_replies: false,
+            skills: vec![],
+            mcp_servers: vec![],
+            hooks: None,
+            prompt: "You are a test.".into(),
+        }
+    }
+}

From 17110732abb86179c8e97ab64f2684bab84b22a1 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:27:14 -0400
Subject: [PATCH 04/28] refactor(persona): align resolve.rs with V3 contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Model split: ResolvedPersona now has separate model (plain ID) and
  provider fields instead of ResolvedModel struct. Maps directly to
  Config.model and PersonaRecord.provider.
- Add resolve_persona_by_name() convenience function for ACP to resolve
  a single persona from a pack directory.
- Keep Vec<ResolvedMcpServer> (typed) per Hana's recommendation over
  V3's Option<serde_json::Value> — contract should absorb complexity.
- Keep Vec<(String, String)> for goose_env_vars (4 entries max, Vec is fine).

29 resolve tests + 151 total library tests pass.
---
 crates/sprout-persona/src/resolve.rs | 201 +++++++++++++++++++--------
 1 file changed, 140 insertions(+), 61 deletions(-)

diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 1faa2da180..3afd501b71 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -30,14 +30,19 @@ pub struct ResolvedPersona {
     pub avatar: Option<String>,
     pub version: String,
 
-    // Effective prompt (persona body + pack_instructions appended)
+    // → Config.system_prompt (persona body + pack_instructions)
     pub system_prompt: String,
 
-    // Effective behavior (all precedence levels 3-5 resolved)
-    pub model: Option<ResolvedModel>,
+    // → Config.model (plain model ID, post-split)
+    pub model: Option<String>,
+    // → PersonaRecord.provider (for desktop display / GOOSE_PROVIDER env)
+    pub provider: Option<String>,
     pub temperature: Option<f64>,
     pub max_context_tokens: Option<u64>,
+
+    // → Config.subscribe_mode + channels_override
     pub subscribe: Vec<String>,
+    // → mapped to ACP filter rules at startup
     pub triggers: ResolvedTriggers,
     pub thread_replies: bool,
     pub broadcast_replies: bool,
@@ -55,13 +60,6 @@ pub struct ResolvedPersona {
     pub goose_env_vars: Vec<(String, String)>,
 }
 
-/// Provider + model ID, split from `"provider:model-id"`.
-#[derive(Debug, Clone, PartialEq)]
-pub struct ResolvedModel {
-    pub provider: String,
-    pub model_id: String,
-}
-
 /// An MCP server with env values as literals (no interpolation in this PR).
 #[derive(Debug, Clone, PartialEq)]
 pub struct ResolvedMcpServer {
@@ -121,7 +119,7 @@ pub fn resolve_loaded_pack(
 
     let mut personas = Vec::with_capacity(loaded.personas.len());
     for lp in &loaded.personas {
-        personas.push(resolve_persona(lp, pack_version, pack_instructions, shared_mcp, pack_dir));
+        personas.push(resolve_one_persona(lp, pack_version, pack_instructions, shared_mcp, pack_dir));
     }
 
     Ok(ResolvedPack {
@@ -134,9 +132,21 @@ pub fn resolve_loaded_pack(
     })
 }
 
+/// Resolve a single persona by name from a pack directory.
+///
+/// Convenience wrapper: loads the pack, finds the named persona, resolves it.
+/// Returns `PackError::PersonaNotFound` if no persona with that name exists.
+pub fn resolve_persona_by_name(pack_dir: &Path, name: &str) -> Result<ResolvedPersona, PackError> {
+    let pack = resolve_pack(pack_dir)?;
+    pack.personas
+        .into_iter()
+        .find(|p| p.name == name)
+        .ok_or_else(|| PackError::PersonaNotFound(pack_dir.join(name)))
+}
+
 // ── Per-persona resolution ────────────────────────────────────────────────────
 
-fn resolve_persona(
+fn resolve_one_persona(
     lp: &LoadedPersona,
     pack_version: &str,
     pack_instructions: Option<&str>,
@@ -144,18 +154,26 @@ fn resolve_persona(
     pack_dir: &Path,
 ) -> ResolvedPersona {
     let system_prompt = compose_prompt(&lp.prompt, pack_instructions);
-    let model = lp.model.as_deref().and_then(resolve_model);
+
+    // Split "provider:model-id" into separate fields (V3 contract).
+    let (provider, model) = match lp.model.as_deref() {
+        Some(s) if !s.trim().is_empty() => {
+            let (prov, id) = split_model(s);
+            (
+                prov.filter(|p| !p.is_empty()).map(str::to_owned),
+                Some(id.to_owned()),
+            )
+        }
+        _ => (None, None),
+    };
+
     let triggers = resolve_triggers(lp.respond_to.as_ref());
     let mcp_servers = merge_mcp_servers(shared_mcp, &lp.mcp_servers);
     let hooks = resolve_hooks(lp.hooks.as_ref(), pack_dir);
     let goose_env_vars = project_env_vars(lp);
 
-    // Version: persona version if set, otherwise pack version.
-    let version = lp
-        .source_path
-        .to_str()
-        .and_then(|_| None::<String>) // persona doesn't store version directly
-        .unwrap_or_else(|| pack_version.to_owned());
+    // Version: pack version (LoadedPersona doesn't carry a per-persona version).
+    let version = pack_version.to_owned();
 
     ResolvedPersona {
         name: lp.name.clone(),
@@ -165,6 +183,7 @@ fn resolve_persona(
         version,
         system_prompt,
         model,
+        provider,
         temperature: lp.temperature,
         max_context_tokens: lp.max_context_tokens,
         subscribe: lp.subscribe.clone(),
@@ -190,20 +209,6 @@ fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> Stri
     }
 }
 
-// ── Model resolution ──────────────────────────────────────────────────────────
-
-/// Split `"provider:model-id"` into a `ResolvedModel`.
-fn resolve_model(model_str: &str) -> Option<ResolvedModel> {
-    if model_str.trim().is_empty() {
-        return None;
-    }
-    let (provider, model_id) = split_model(model_str);
-    Some(ResolvedModel {
-        provider: provider.unwrap_or("").to_owned(),
-        model_id: model_id.to_owned(),
-    })
-}
-
 // ── Triggers resolution ───────────────────────────────────────────────────────
 
 /// Convert `RespondToData` to `ResolvedTriggers` (renamed per spec discussion).
@@ -372,28 +377,6 @@ mod tests {
         assert_eq!(result, "You are a bot.");
     }
 
-    // ── resolve_model ─────────────────────────────────────────────────────
-
-    #[test]
-    fn resolve_model_with_provider() {
-        let m = resolve_model("anthropic:claude-sonnet-4-20250514").unwrap();
-        assert_eq!(m.provider, "anthropic");
-        assert_eq!(m.model_id, "claude-sonnet-4-20250514");
-    }
-
-    #[test]
-    fn resolve_model_without_provider() {
-        let m = resolve_model("gpt-4o").unwrap();
-        assert_eq!(m.provider, "");
-        assert_eq!(m.model_id, "gpt-4o");
-    }
-
-    #[test]
-    fn resolve_model_empty_returns_none() {
-        assert!(resolve_model("").is_none());
-        assert!(resolve_model("  ").is_none());
-    }
-
     // ── resolve_triggers ──────────────────────────────────────────────────
 
     #[test]
@@ -606,6 +589,7 @@ mod tests {
         assert_eq!(p.name, "bot");
         assert_eq!(p.system_prompt, "You are Bot.\n");
         assert!(p.model.is_none());
+        assert!(p.provider.is_none());
         assert!(p.triggers.mentions); // built-in default
         assert!(p.mcp_servers.is_empty());
         assert!(p.goose_env_vars.is_empty());
@@ -646,10 +630,9 @@ mod tests {
         assert!(p.system_prompt.contains("# Team Instructions"));
         assert!(p.system_prompt.contains("Always be helpful."));
 
-        // Model resolved
-        let m = p.model.as_ref().unwrap();
-        assert_eq!(m.provider, "anthropic");
-        assert_eq!(m.model_id, "claude-sonnet-4-20250514");
+        // Model split into separate fields (V3 contract)
+        assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-20250514"));
+        assert_eq!(p.provider.as_deref(), Some("anthropic"));
 
         // Env vars projected
         let env_map: HashMap<&str, &str> = p
@@ -703,9 +686,11 @@ mod tests {
         let lep = pack.personas.iter().find(|p| p.name == "lep").unwrap();
 
         // pip overrides model
-        assert_eq!(pip.model.as_ref().unwrap().model_id, "claude-4-opus-20250514");
+        assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));
+        assert_eq!(pip.provider.as_deref(), Some("anthropic"));
         // lep inherits model from defaults
-        assert_eq!(lep.model.as_ref().unwrap().model_id, "claude-sonnet-4-20250514");
+        assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));
+        assert_eq!(lep.provider.as_deref(), Some("anthropic"));
 
         // pip inherits temperature from defaults
         assert_eq!(pip.temperature, Some(0.7));
@@ -722,6 +707,100 @@ mod tests {
         assert!(lep.thread_replies);
     }
 
+    // ── resolve_persona_by_name ──────────────────────────────────────────
+
+    #[test]
+    fn resolve_persona_by_name_found() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md","agents/b.persona.md"]}"#,
+        ).unwrap();
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\nAlpha prompt.\n",
+        ).unwrap();
+        std::fs::write(
+            dir.join("agents/b.persona.md"),
+            "---\nname: beta\ndisplay_name: Beta\ndescription: Second.\n---\nBeta prompt.\n",
+        ).unwrap();
+
+        let p = resolve_persona_by_name(dir, "beta").unwrap();
+        assert_eq!(p.name, "beta");
+        assert_eq!(p.display_name, "Beta");
+        assert!(p.system_prompt.contains("Beta prompt."));
+    }
+
+    #[test]
+    fn resolve_persona_by_name_not_found() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
+        ).unwrap();
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\n",
+        ).unwrap();
+
+        let err = resolve_persona_by_name(dir, "nonexistent").unwrap_err();
+        assert!(matches!(err, PackError::PersonaNotFound(_)));
+    }
+
+    // ── model split ───────────────────────────────────────────────────────
+
+    #[test]
+    fn model_split_provider_and_id() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
+        ).unwrap();
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: openai:gpt-4o\n---\n",
+        ).unwrap();
+
+        let pack = resolve_pack(dir).unwrap();
+        let p = &pack.personas[0];
+        assert_eq!(p.model.as_deref(), Some("gpt-4o"));
+        assert_eq!(p.provider.as_deref(), Some("openai"));
+    }
+
+    #[test]
+    fn model_no_provider() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
+        ).unwrap();
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: gpt-4o\n---\n",
+        ).unwrap();
+
+        let pack = resolve_pack(dir).unwrap();
+        let p = &pack.personas[0];
+        assert_eq!(p.model.as_deref(), Some("gpt-4o"));
+        assert!(p.provider.is_none());
+    }
+
     // ── Test helpers ──────────────────────────────────────────────────────
 
     fn stub_persona(

From 0316883436da2da75860c1037609a062a407c21a Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:29:17 -0400
Subject: [PATCH 05/28] security(persona): add PERSONA_ENV_ALLOWLIST for env
 var projection

Per Lep's security review (F1): without an explicit allowlist, a
malicious pack could theoretically inject arbitrary env vars (PATH,
LD_PRELOAD, SPROUT_PRIVATE_KEY). The projection function already only
produces GOOSE_* vars by construction, but the allowlist makes the
boundary explicit, documented, and testable.

Allowed vars: GOOSE_PROVIDER, GOOSE_MODEL, GOOSE_TEMPERATURE,
GOOSE_CONTEXT_LIMIT.

Also documents hooks/skills fields as 'reserved for future use, not
yet wired' per Hana's review caveat.

152 unit tests pass including new env_vars_only_allowlisted test.
---
 crates/sprout-persona/src/resolve.rs | 36 ++++++++++++++++++++++++++--
 1 file changed, 34 insertions(+), 2 deletions(-)

diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 3afd501b71..53a60f875f 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -50,10 +50,10 @@ pub struct ResolvedPersona {
     // Effective MCP (pack shared + persona merged, literals preserved)
     pub mcp_servers: Vec<ResolvedMcpServer>,
 
-    // Hooks (parsed, not executed)
+    // Hooks (parsed, not executed — reserved for future use, not yet wired)
     pub hooks: Option<ResolvedHooks>,
 
-    // Skills (bare names)
+    // Skills (bare names — reserved for future use, not yet wired)
     pub skills: Vec<String>,
 
     // Env var projection for goose subprocess
@@ -322,10 +322,28 @@ fn resolve_hooks(
 
 // ── Env var projection ────────────────────────────────────────────────────────
 
+/// Allowlist of env vars that persona packs may project.
+///
+/// Security boundary: pack authors cannot inject arbitrary env vars
+/// (PATH, LD_PRELOAD, SPROUT_PRIVATE_KEY, etc.). Only these four
+/// GOOSE_* vars are permitted. The projection function below only
+/// produces these keys by construction, but the allowlist makes the
+/// boundary explicit and testable.
+pub const PERSONA_ENV_ALLOWLIST: &[&str] = &[
+    "GOOSE_PROVIDER",
+    "GOOSE_MODEL",
+    "GOOSE_TEMPERATURE",
+    "GOOSE_CONTEXT_LIMIT",
+];
+
 /// Project persona config into goose subprocess env vars.
 ///
 /// Pure function — does NOT read the current process env.
 /// ACP is responsible for filtering based on operator precedence (level 1).
+///
+/// Only produces vars in [`PERSONA_ENV_ALLOWLIST`]. This is enforced by
+/// construction (the function only maps known config fields to known var
+/// names) and verified by the `env_vars_only_allowlisted` test.
 fn project_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
     let mut vars = Vec::new();
 
@@ -555,6 +573,20 @@ mod tests {
         assert_eq!(vars.len(), 4); // PROVIDER, MODEL, TEMPERATURE, CONTEXT_LIMIT
     }
 
+    #[test]
+    fn env_vars_only_allowlisted() {
+        // Security: verify all projected vars are in the allowlist.
+        // A malicious pack cannot inject PATH, LD_PRELOAD, etc.
+        let lp = stub_persona(Some("openai:gpt-4o"), Some(0.5), Some(16384));
+        let vars = project_env_vars(&lp);
+        for (key, _) in &vars {
+            assert!(
+                PERSONA_ENV_ALLOWLIST.contains(&key.as_str()),
+                "env var '{key}' is not in PERSONA_ENV_ALLOWLIST"
+            );
+        }
+    }
+
     // ── Full pipeline (resolve_pack via filesystem) ───────────────────────
 
     #[test]

From 911ec23f49a3ed401cd996bdfe38f89b7978a626 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:37:44 -0400
Subject: [PATCH 06/28] =?UTF-8?q?fix(persona):=20cascade=20respond=5Fto?=
 =?UTF-8?q?=E2=86=92triggers=20rename=20+=20hooks=20security=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Tilly renamed RespondToData→TriggersData and respond_to→triggers in
merge.rs and persona.rs. This cascades the rename through:
- pack.rs: import, struct field, assignments, test fixtures
- resolve.rs: import, function signature, test helper
- legacy.rs: struct construction, test assertion

Also fixes pack.rs subscribe type mismatch (Option<Vec<String>> from
ResolvedConfig → Vec<String> on LoadedPersona via unwrap_or_default).

Security fix (Lep F1): resolve_hooks() now stores raw relative strings
instead of resolving to absolute paths. Prevents path traversal via
hooks.on_start: '../../../../usr/bin/evil'. The PR that wires hook
execution MUST validate through safe_resolve() before use.

156 unit tests pass.
---
 crates/sprout-persona/src/legacy.rs  | 719 ++++++++++++++++++++++++
 crates/sprout-persona/src/pack.rs    | 790 +++++++++++++++++++++++++++
 crates/sprout-persona/src/resolve.rs |  65 ++-
 3 files changed, 1541 insertions(+), 33 deletions(-)
 create mode 100644 crates/sprout-persona/src/legacy.rs
 create mode 100644 crates/sprout-persona/src/pack.rs

diff --git a/crates/sprout-persona/src/legacy.rs b/crates/sprout-persona/src/legacy.rs
new file mode 100644
index 0000000000..3342cc787d
--- /dev/null
+++ b/crates/sprout-persona/src/legacy.rs
@@ -0,0 +1,719 @@
+//! Legacy JSON persona format adapter.
+//!
+//! Reads the flat `.persona.json` format used by the Sprout desktop app
+//! and converts to `.persona.md` (YAML frontmatter + markdown body).
+//!
+//! This is a read-only compatibility shim — the JSON format is deprecated.
+//! The ACP harness only consumes `.persona.md`; the desktop converts at
+//! deploy time via this adapter.
+
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use crate::persona::PersonaConfig;
+use crate::validate::ValidationDiagnostic;
+
+// ── Errors ────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, thiserror::Error)]
+pub enum LegacyError {
+    #[error("failed to read file: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("failed to parse JSON: {0}")]
+    Json(#[from] serde_json::Error),
+
+    #[error("migration error: {0}")]
+    Migration(String),
+}
+
+// ── Legacy persona record (desktop JSON format) ──────────────────────────────
+
+/// The flat JSON persona format stored by the Sprout desktop app.
+///
+/// Source: `desktop/src-tauri/src/managed_agents/types.rs` → `PersonaRecord`.
+/// This struct mirrors that layout for deserialization — we don't depend on
+/// the desktop crate directly.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LegacyPersonaRecord {
+    pub id: String,
+    pub display_name: String,
+    #[serde(default)]
+    pub avatar_url: Option<String>,
+    pub system_prompt: String,
+    #[serde(default)]
+    pub provider: Option<String>,
+    #[serde(default)]
+    pub model: Option<String>,
+    #[serde(default)]
+    pub name_pool: Vec<String>,
+    #[serde(default)]
+    pub is_builtin: bool,
+    #[serde(default = "default_true")]
+    pub is_active: bool,
+    #[serde(default)]
+    pub created_at: String,
+    #[serde(default)]
+    pub updated_at: String,
+}
+
+fn default_true() -> bool {
+    true
+}
+
+// ── Loading ──────────────────────────────────────────────────────────────────
+
+/// Load legacy persona records from a JSON file.
+///
+/// Accepts either a single `LegacyPersonaRecord` object or an array of them
+/// (the desktop stores an array in `personas.json`).
+pub fn load_legacy_json(path: &Path) -> Result<Vec<LegacyPersonaRecord>, LegacyError> {
+    const MAX_LEGACY_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
+    let metadata = std::fs::metadata(path)?;
+    if metadata.len() > MAX_LEGACY_FILE_SIZE {
+        return Err(LegacyError::Migration(format!(
+            "file too large: {} bytes (max {})",
+            metadata.len(),
+            MAX_LEGACY_FILE_SIZE
+        )));
+    }
+    let content = std::fs::read_to_string(path)?;
+    let trimmed = content.trim();
+
+    if trimmed.starts_with('[') {
+        Ok(serde_json::from_str::<Vec<LegacyPersonaRecord>>(trimmed)?)
+    } else if trimmed.starts_with('{') {
+        Ok(vec![serde_json::from_str::<LegacyPersonaRecord>(trimmed)?])
+    } else {
+        Err(LegacyError::Migration(
+            "file is not a JSON object or array".into(),
+        ))
+    }
+}
+
+// ── Field mapping ────────────────────────────────────────────────────────────
+
+/// Derive the V7 `name` field from the legacy `id`.
+///
+/// Strips the `builtin:` prefix if present, lowercases, and replaces
+/// spaces/special chars with hyphens.
+pub fn derive_name(id: &str) -> String {
+    let stripped = id
+        .strip_prefix("builtin:")
+        .or_else(|| id.strip_prefix("custom:"))
+        .unwrap_or(id);
+
+    stripped
+        .to_lowercase()
+        .chars()
+        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
+        .collect::<String>()
+        .trim_matches('-')
+        .to_string()
+}
+
+/// Merge `provider` and `model` into the V7 `"provider:model-id"` format.
+///
+/// Cases:
+/// 1. Both present → `"provider:model-id"`
+/// 2. Only model   → `"model-id"` (no colon)
+/// 3. Only provider → `None` (provider alone is not useful)
+/// 4. Neither      → `None`
+pub fn merge_provider_model(
+    provider: Option<&str>,
+    model: Option<&str>,
+) -> Option<String> {
+    match (provider, model) {
+        (Some(p), Some(m)) if !p.is_empty() && !m.is_empty() => {
+            Some(format!("{p}:{m}"))
+        }
+        (_, Some(m)) if !m.is_empty() => Some(m.to_string()),
+        _ => None,
+    }
+}
+
+/// Extract a one-line description from the system prompt.
+///
+/// Takes the first non-empty line, truncated to at most 120 bytes of UTF-8.
+/// Uses char boundaries to avoid slicing through multibyte codepoints.
+/// Falls back to "Migrated persona" if the prompt is empty.
+pub fn derive_description(system_prompt: &str) -> String {
+    system_prompt
+        .lines()
+        .map(str::trim)
+        .find(|line| !line.is_empty())
+        .map(|line| {
+            if line.len() > 119 {
+                // Find the last char boundary ending at or before byte 119,
+                // so the result (content + '…') is at most 120 chars.
+                let truncate_at = line
+                    .char_indices()
+                    .take_while(|(i, _)| *i < 119)
+                    .last()
+                    .map(|(i, c)| i + c.len_utf8())
+                    .unwrap_or(119);
+                format!("{}…", &line[..truncate_at])
+            } else {
+                line.to_string()
+            }
+        })
+        .unwrap_or_else(|| "Migrated persona".to_string())
+}
+
+// ── Migration: JSON → .persona.md ────────────────────────────────────────────
+
+/// Convert a single legacy record to `.persona.md` content (YAML frontmatter
+/// + markdown body).
+///
+/// Returns `(filename, content)` where filename is `{name}.persona.md`.
+pub fn legacy_to_persona_md(
+    record: &LegacyPersonaRecord,
+) -> Result<(String, String), LegacyError> {
+    let name = derive_name(&record.id);
+    if name.is_empty() {
+        return Err(LegacyError::Migration(format!(
+            "could not derive a valid name from id {:?}",
+            record.id
+        )));
+    }
+    if record.display_name.trim().is_empty() {
+        return Err(LegacyError::Migration("display_name is empty".into()));
+    }
+
+    let description = derive_description(&record.system_prompt);
+    let model = merge_provider_model(
+        record.provider.as_deref(),
+        record.model.as_deref(),
+    );
+
+    // Build YAML frontmatter using serde_yaml for correct escaping.
+    // BTreeMap keeps keys in alphabetical order for deterministic output.
+    let mut fm = serde_json::Map::new();
+    fm.insert("name".into(), serde_json::Value::String(name.clone()));
+    fm.insert("display_name".into(), serde_json::Value::String(record.display_name.clone()));
+    fm.insert("description".into(), serde_json::Value::String(description));
+
+    if let Some(ref avatar) = record.avatar_url {
+        // Skip data: URIs — they're inline base64, not pack-relative paths.
+        if !avatar.starts_with("data:") && !avatar.is_empty() {
+            fm.insert("avatar".into(), serde_json::Value::String(avatar.clone()));
+        }
+    }
+
+    if let Some(ref m) = model {
+        fm.insert("model".into(), serde_json::Value::String(m.clone()));
+    }
+
+    let yaml_value = serde_json::Value::Object(fm);
+    let frontmatter = serde_yaml::to_string(&yaml_value)
+        .map_err(|e| LegacyError::Migration(format!("failed to serialize frontmatter: {e}")))?;
+    // serde_yaml emits a trailing newline; trim it since we add our own.
+    let frontmatter = frontmatter.trim_end();
+    let body = &record.system_prompt;
+
+    let content = format!("---\n{frontmatter}\n---\n\n{body}");
+    let filename = format!("{name}.persona.md");
+
+    Ok((filename, content))
+}
+
+/// Migrate a legacy JSON file to a directory of `.persona.md` files.
+///
+/// Creates the output directory if it doesn't exist. Skips inactive and
+/// built-in personas by default (controlled by `include_builtin` and
+/// `include_inactive`).
+pub fn migrate_json_to_md(
+    input_path: &Path,
+    output_dir: &Path,
+    include_builtin: bool,
+    include_inactive: bool,
+) -> Result<MigrationReport, LegacyError> {
+    let records = load_legacy_json(input_path)?;
+    std::fs::create_dir_all(output_dir)?;
+
+    let mut report = MigrationReport::default();
+
+    for record in &records {
+        if !include_builtin && record.is_builtin {
+            report.skipped_builtin += 1;
+            continue;
+        }
+        if !include_inactive && !record.is_active {
+            report.skipped_inactive += 1;
+            continue;
+        }
+
+        match legacy_to_persona_md(record) {
+            Ok((filename, content)) => {
+                let out_path = output_dir.join(&filename);
+                if out_path.exists() {
+                    report.diagnostics.push(ValidationDiagnostic::Warning(
+                        format!("output file already exists, skipping: {}", out_path.display()),
+                    ));
+                    continue;
+                }
+                std::fs::write(&out_path, &content)?;
+                report.migrated.push(filename);
+            }
+            Err(e) => {
+                report.diagnostics.push(ValidationDiagnostic::Error(
+                    format!("failed to migrate {:?}: {e}", record.id),
+                ));
+            }
+        }
+    }
+
+    Ok(report)
+}
+
+/// Summary of a migration run.
+#[derive(Debug, Default)]
+pub struct MigrationReport {
+    pub migrated: Vec<String>,
+    pub skipped_builtin: usize,
+    pub skipped_inactive: usize,
+    pub diagnostics: Vec<ValidationDiagnostic>,
+}
+
+impl std::fmt::Display for MigrationReport {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        writeln!(f, "Migration complete:")?;
+        writeln!(f, "  Migrated: {}", self.migrated.len())?;
+        for name in &self.migrated {
+            writeln!(f, "    ✓ {name}")?;
+        }
+        if self.skipped_builtin > 0 {
+            writeln!(f, "  Skipped (built-in): {}", self.skipped_builtin)?;
+        }
+        if self.skipped_inactive > 0 {
+            writeln!(f, "  Skipped (inactive): {}", self.skipped_inactive)?;
+        }
+        for w in &self.diagnostics {
+            writeln!(f, "  {w}")?;
+        }
+        Ok(())
+    }
+}
+
+// ── Adapter: LegacyPersonaRecord → PersonaConfig ─────────────────────────────
+
+/// Convert a legacy JSON persona record into a typed `PersonaConfig`.
+///
+/// This is the bridge between the old flat JSON format and the new V7 struct.
+/// Fields that don't exist in the legacy format (`skills`, `mcp_servers`,
+/// `subscribe`, `respond_to`, `hooks`, etc.) get their default/empty values.
+pub fn legacy_to_persona_config(record: &LegacyPersonaRecord) -> Result<PersonaConfig, LegacyError> {
+    let name = derive_name(&record.id);
+    if name.is_empty() {
+        return Err(LegacyError::Migration(format!(
+            "could not derive a valid name from id {:?}",
+            record.id
+        )));
+    }
+    if record.display_name.trim().is_empty() {
+        return Err(LegacyError::Migration("display_name is empty".into()));
+    }
+
+    Ok(PersonaConfig {
+        name,
+        display_name: record.display_name.clone(),
+        avatar: record.avatar_url.as_ref()
+            .filter(|a| !a.starts_with("data:") && !a.is_empty())
+            .cloned(),
+        description: derive_description(&record.system_prompt),
+        version: None,
+        author: None,
+        skills: Vec::new(),
+        mcp_servers: Vec::new(),
+        subscribe: None,
+        triggers: None,
+        model: merge_provider_model(
+            record.provider.as_deref(),
+            record.model.as_deref(),
+        ),
+        temperature: None,
+        max_context_tokens: None,
+        thread_replies: None,
+        broadcast_replies: None,
+        hooks: None,
+        prompt: record.system_prompt.clone(),
+    })
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── derive_name ──────────────────────────────────────────────────────
+
+    #[test]
+    fn derive_name_strips_builtin_prefix() {
+        assert_eq!(derive_name("builtin:solo"), "solo");
+    }
+
+    #[test]
+    fn derive_name_strips_custom_prefix() {
+        assert_eq!(derive_name("custom:my-agent"), "my-agent");
+    }
+
+    #[test]
+    fn derive_name_lowercases() {
+        assert_eq!(derive_name("MyAgent"), "myagent");
+    }
+
+    #[test]
+    fn derive_name_replaces_spaces() {
+        assert_eq!(derive_name("My Cool Agent"), "my-cool-agent");
+    }
+
+    #[test]
+    fn derive_name_plain_id() {
+        assert_eq!(derive_name("lep"), "lep");
+    }
+
+    // ── merge_provider_model ─────────────────────────────────────────────
+
+    #[test]
+    fn merge_both_present() {
+        assert_eq!(
+            merge_provider_model(Some("anthropic"), Some("claude-sonnet-4-20250514")),
+            Some("anthropic:claude-sonnet-4-20250514".into())
+        );
+    }
+
+    #[test]
+    fn merge_model_only() {
+        assert_eq!(
+            merge_provider_model(None, Some("gpt-4o")),
+            Some("gpt-4o".into())
+        );
+    }
+
+    #[test]
+    fn merge_provider_only() {
+        assert_eq!(merge_provider_model(Some("anthropic"), None), None);
+    }
+
+    #[test]
+    fn merge_neither() {
+        assert_eq!(merge_provider_model(None, None), None);
+    }
+
+    #[test]
+    fn merge_empty_strings() {
+        assert_eq!(merge_provider_model(Some(""), Some("")), None);
+    }
+
+    #[test]
+    fn merge_empty_provider_with_model() {
+        assert_eq!(
+            merge_provider_model(Some(""), Some("gpt-4o")),
+            Some("gpt-4o".into())
+        );
+    }
+
+    // ── derive_description ───────────────────────────────────────────────
+
+    #[test]
+    fn description_from_first_line() {
+        assert_eq!(
+            derive_description("You are a security bot.\nMore details here."),
+            "You are a security bot."
+        );
+    }
+
+    #[test]
+    fn description_skips_empty_lines() {
+        assert_eq!(
+            derive_description("\n\n  \nActual content here."),
+            "Actual content here."
+        );
+    }
+
+    #[test]
+    fn description_empty_prompt() {
+        assert_eq!(derive_description(""), "Migrated persona");
+        assert_eq!(derive_description("   \n  \n  "), "Migrated persona");
+    }
+
+    #[test]
+    fn description_truncates_long_line() {
+        let long = "x".repeat(200);
+        let desc = derive_description(&long);
+        assert!(desc.chars().count() <= 120, "should be at most 120 chars (119 + ellipsis)");
+        assert!(desc.ends_with('…'));
+    }
+
+    #[test]
+    fn description_truncates_multibyte_utf8() {
+        // 200 emoji chars — each is 4 bytes. Old code would panic on byte slice.
+        let long: String = "🔒".repeat(200);
+        let desc = derive_description(&long);
+        assert!(desc.chars().count() <= 120);
+        assert!(desc.ends_with('…'));
+        // Verify it's valid UTF-8 (would panic on construction if not).
+        assert!(desc.len() > 0);
+    }
+
+    #[test]
+    fn description_truncates_multibyte_safely() {
+        // 40 CJK characters = 120 bytes (3 bytes each), then more
+        let long = "你".repeat(50); // 150 bytes
+        let desc = derive_description(&long);
+        assert!(!desc.is_empty());
+        // Should not panic, and should end with …
+        assert!(desc.ends_with('…'));
+    }
+
+    // ── legacy_to_persona_md ─────────────────────────────────────────────
+
+    #[test]
+    fn basic_conversion() {
+        let record = LegacyPersonaRecord {
+            id: "custom:lep".into(),
+            display_name: "Lep 🍀".into(),
+            avatar_url: Some("./avatars/lep.png".into()),
+            system_prompt: "You are Lep, a security reviewer.".into(),
+            provider: Some("anthropic".into()),
+            model: Some("claude-sonnet-4-20250514".into()),
+            name_pool: vec!["clover".into()],
+            is_builtin: false,
+            is_active: true,
+            created_at: "2026-01-01T00:00:00Z".into(),
+            updated_at: "2026-01-01T00:00:00Z".into(),
+        };
+
+        let (filename, content) = legacy_to_persona_md(&record).unwrap();
+        assert_eq!(filename, "lep.persona.md");
+        assert!(content.starts_with("---\n"), "should start with ---");
+        // serde_yaml may or may not quote simple strings; check key: value pairs
+        assert!(content.contains("name:") && content.contains("lep"), "should contain name: lep");
+        assert!(content.contains("display_name:"), "should contain display_name");
+        assert!(content.contains("model:") && content.contains("anthropic:claude-sonnet-4-20250514"), "should contain model");
+        assert!(content.contains("avatar:") && content.contains("./avatars/lep.png"), "should contain avatar");
+        assert!(content.contains("You are Lep, a security reviewer."), "should contain system prompt");
+        // name_pool should NOT appear
+        assert!(!content.contains("name_pool"), "name_pool should not appear");
+        assert!(!content.contains("clover"), "name_pool values should not appear");
+    }
+
+    #[test]
+    fn conversion_skips_data_uri_avatar() {
+        let record = LegacyPersonaRecord {
+            id: "builtin:solo".into(),
+            display_name: "Solo".into(),
+            avatar_url: Some("data:image/png;base64,iVBOR...".into()),
+            system_prompt: "You are Solo.".into(),
+            provider: None,
+            model: None,
+            name_pool: vec![],
+            is_builtin: true,
+            is_active: true,
+            created_at: String::new(),
+            updated_at: String::new(),
+        };
+
+        let (_, content) = legacy_to_persona_md(&record).unwrap();
+        assert!(!content.contains("avatar:"));
+        assert!(!content.contains("data:image"));
+    }
+
+    #[test]
+    fn conversion_no_model() {
+        let record = LegacyPersonaRecord {
+            id: "custom:plain".into(),
+            display_name: "Plain".into(),
+            avatar_url: None,
+            system_prompt: "A simple agent.".into(),
+            provider: None,
+            model: None,
+            name_pool: vec![],
+            is_builtin: false,
+            is_active: true,
+            created_at: String::new(),
+            updated_at: String::new(),
+        };
+
+        let (_, content) = legacy_to_persona_md(&record).unwrap();
+        assert!(!content.contains("model:"));
+    }
+
+    #[test]
+    fn conversion_preserves_prompt_whitespace() {
+        // Fix #4: migration must not trim leading/trailing whitespace from prompts.
+        let prompt = "\n  You are Lep.\n\nBe helpful.\n  ";
+        let record = LegacyPersonaRecord {
+            id: "custom:lep".into(),
+            display_name: "Lep".into(),
+            avatar_url: None,
+            system_prompt: prompt.into(),
+            provider: None,
+            model: None,
+            name_pool: vec![],
+            is_builtin: false,
+            is_active: true,
+            created_at: String::new(),
+            updated_at: String::new(),
+        };
+
+        let (_, content) = legacy_to_persona_md(&record).unwrap();
+        // The body section (after the closing ---\n\n) must contain the prompt verbatim.
+        let body_start = content.find("---\n\n").unwrap() + 5;
+        let body = &content[body_start..];
+        assert_eq!(body, prompt, "prompt must be written as-is, not trimmed");
+    }
+
+    // ── load_legacy_json ─────────────────────────────────────────────────
+
+    #[test]
+    fn load_single_object() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("single.json");
+        std::fs::write(
+            &path,
+            r#"{"id":"custom:test","display_name":"Test","system_prompt":"Hello","created_at":"","updated_at":""}"#,
+        )
+        .unwrap();
+
+        let records = load_legacy_json(&path).unwrap();
+        assert_eq!(records.len(), 1);
+        assert_eq!(records[0].id, "custom:test");
+    }
+
+    #[test]
+    fn load_array() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("array.json");
+        std::fs::write(
+            &path,
+            r#"[{"id":"a","display_name":"A","system_prompt":"","created_at":"","updated_at":""},{"id":"b","display_name":"B","system_prompt":"","created_at":"","updated_at":""}]"#,
+        )
+        .unwrap();
+
+        let records = load_legacy_json(&path).unwrap();
+        assert_eq!(records.len(), 2);
+    }
+
+    // ── legacy_to_persona_config ─────────────────────────────────────────
+
+    fn make_record(id: &str, prompt: &str) -> LegacyPersonaRecord {
+        LegacyPersonaRecord {
+            id: id.into(),
+            display_name: "Test Agent".into(),
+            avatar_url: Some("./avatars/test.png".into()),
+            system_prompt: prompt.into(),
+            provider: Some("anthropic".into()),
+            model: Some("claude-sonnet-4-20250514".into()),
+            name_pool: vec!["alpha".into()],
+            is_builtin: false,
+            is_active: true,
+            created_at: String::new(),
+            updated_at: String::new(),
+        }
+    }
+
+    #[test]
+    fn adapter_basic() {
+        let record = make_record("custom:lep", "You are Lep.");
+        let config = legacy_to_persona_config(&record).unwrap();
+        assert_eq!(config.name, "lep");
+        assert_eq!(config.display_name, "Test Agent");
+        assert_eq!(config.avatar.as_deref(), Some("./avatars/test.png"));
+        assert_eq!(config.model.as_deref(), Some("anthropic:claude-sonnet-4-20250514"));
+        assert_eq!(config.prompt, "You are Lep.");
+        assert!(config.skills.is_empty());
+        assert!(config.mcp_servers.is_empty());
+        assert!(config.subscribe.is_none());
+        assert!(config.triggers.is_none());
+        assert!(config.hooks.is_none());
+    }
+
+    #[test]
+    fn adapter_skips_data_uri_avatar() {
+        let mut record = make_record("builtin:solo", "Solo prompt.");
+        record.avatar_url = Some("data:image/png;base64,abc".into());
+        let config = legacy_to_persona_config(&record).unwrap();
+        assert!(config.avatar.is_none());
+    }
+
+    #[test]
+    fn adapter_no_model() {
+        let mut record = make_record("custom:plain", "Plain.");
+        record.provider = None;
+        record.model = None;
+        let config = legacy_to_persona_config(&record).unwrap();
+        assert!(config.model.is_none());
+    }
+
+    #[test]
+    fn adapter_preserves_prompt_whitespace() {
+        // Fix #4: legacy_to_persona_config must not trim the system prompt.
+        let prompt = "\n  You are Lep.\n\nBe helpful.\n  ";
+        let record = make_record("custom:lep", prompt);
+        let config = legacy_to_persona_config(&record).unwrap();
+        assert_eq!(config.prompt, prompt, "prompt must be stored as-is, not trimmed");
+    }
+
+    // ── empty display_name validation ────────────────────────────────────
+
+    #[test]
+    fn empty_display_name_is_rejected_by_md() {
+        let mut record = make_record("custom:lep", "You are Lep.");
+        record.display_name = "".into();
+        let err = legacy_to_persona_md(&record).unwrap_err();
+        assert!(
+            matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")),
+            "expected Migration error mentioning display_name, got: {err}"
+        );
+    }
+
+    #[test]
+    fn whitespace_only_display_name_is_rejected_by_md() {
+        let mut record = make_record("custom:lep", "You are Lep.");
+        record.display_name = "   ".into();
+        let err = legacy_to_persona_md(&record).unwrap_err();
+        assert!(matches!(err, LegacyError::Migration(_)));
+    }
+
+    #[test]
+    fn empty_display_name_is_rejected_by_config() {
+        let mut record = make_record("custom:lep", "You are Lep.");
+        record.display_name = "".into();
+        let err = legacy_to_persona_config(&record).unwrap_err();
+        assert!(
+            matches!(err, LegacyError::Migration(ref msg) if msg.contains("display_name")),
+            "expected Migration error mentioning display_name, got: {err}"
+        );
+    }
+
+    #[test]
+    fn whitespace_only_display_name_is_rejected_by_config() {
+        let mut record = make_record("custom:lep", "You are Lep.");
+        record.display_name = "   ".into();
+        let err = legacy_to_persona_config(&record).unwrap_err();
+        assert!(matches!(err, LegacyError::Migration(_)));
+    }
+
+    // ── Round-trip: JSON → .persona.md → parse back ──────────────────────
+
+    #[test]
+    fn round_trip_json_to_md_to_config() {
+        let record = make_record("custom:roundtrip", "You are a round-trip test agent.\n\nBe helpful.");
+        let (filename, md_content) = legacy_to_persona_md(&record).unwrap();
+        assert_eq!(filename, "roundtrip.persona.md");
+
+        // Parse the generated .persona.md back using the real parser.
+        let parsed = crate::persona::parse_persona_md(&md_content).unwrap();
+
+        assert_eq!(parsed.name, "roundtrip");
+        assert_eq!(parsed.display_name, "Test Agent");
+        assert_eq!(parsed.model.as_deref(), Some("anthropic:claude-sonnet-4-20250514"));
+        assert_eq!(parsed.avatar.as_deref(), Some("./avatars/test.png"));
+        // The prompt should survive the round-trip.
+        assert!(parsed.prompt.contains("You are a round-trip test agent."));
+        assert!(parsed.prompt.contains("Be helpful."));
+    }
+}
diff --git a/crates/sprout-persona/src/pack.rs b/crates/sprout-persona/src/pack.rs
new file mode 100644
index 0000000000..e17774da3f
--- /dev/null
+++ b/crates/sprout-persona/src/pack.rs
@@ -0,0 +1,790 @@
+/// Pack directory loader.
+///
+/// Reads a pack directory and produces a fully loaded [`LoadedPack`].
+///
+/// Directory layout expected:
+/// ```text
+/// <pack_root>/
+///   .plugin/
+///     plugin.json          ← manifest
+///   personas/
+///     <name>.persona.md    ← one file per persona
+///   instructions.md        ← optional pack-level instructions
+///   .mcp.json              ← optional shared MCP config
+///   skills/                ← optional skills directory
+/// ```
+
+use std::{
+    collections::HashMap,
+    path::{Component, Path, PathBuf},
+};
+
+use crate::manifest::{self, ManifestError};
+use crate::merge::{resolve_persona_config, HooksData, TriggersData};
+use crate::persona::{self, PersonaConfig};
+
+// ── Error ─────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, thiserror::Error)]
+pub enum PackError {
+    #[error("manifest not found at {0}")]
+    ManifestNotFound(PathBuf),
+
+    #[error("failed to read {path}: {source}")]
+    Io {
+        path: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+
+    #[error("failed to parse manifest: {0}")]
+    ManifestParse(String),
+
+    #[error("persona file not found: {0}")]
+    PersonaNotFound(PathBuf),
+
+    #[error("invalid persona file {path}: {reason}")]
+    PersonaParse { path: PathBuf, reason: String },
+
+    #[error("path traversal rejected: {0}")]
+    PathTraversal(String),
+
+    #[error("path escapes pack root: {0}")]
+    PathEscape(PathBuf),
+
+    #[error("failed to parse .mcp.json at {path}: {reason}")]
+    McpConfigParse { path: PathBuf, reason: String },
+}
+
+impl From<ManifestError> for PackError {
+    fn from(e: ManifestError) -> Self {
+        PackError::ManifestParse(e.to_string())
+    }
+}
+
+// ── Public types ──────────────────────────────────────────────────────────────
+
+/// A fully loaded persona pack.
+#[derive(Debug)]
+pub struct LoadedPack {
+    pub manifest: PackManifestData,
+    pub personas: Vec<LoadedPersona>,
+    /// Content of instructions.md, if present.
+    pub pack_instructions: Option<String>,
+    /// Raw .mcp.json content, if present.
+    pub shared_mcp_config: Option<serde_json::Value>,
+    /// Path to the skills/ directory, if it exists.
+    pub skills_dir: Option<PathBuf>,
+}
+
+/// A persona with its resolved effective config.
+#[derive(Debug)]
+pub struct LoadedPersona {
+    pub source_path: PathBuf,
+    pub name: String,
+    pub display_name: String,
+    pub description: String,
+    pub avatar: Option<String>,
+    pub model: Option<String>,
+    pub temperature: Option<f64>,
+    pub max_context_tokens: Option<u64>,
+    pub subscribe: Vec<String>,
+    pub triggers: Option<TriggersData>,
+    pub thread_replies: bool,
+    pub broadcast_replies: bool,
+    pub skills: Vec<String>,
+    /// Raw MCP server configs.
+    pub mcp_servers: Vec<serde_json::Value>,
+    pub hooks: Option<HooksData>,
+    /// The markdown body (system prompt).
+    pub prompt: String,
+}
+
+/// Minimal manifest data needed by the pack loader.
+#[derive(Debug)]
+pub struct PackManifestData {
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    /// Relative paths to .persona.md files.
+    pub personas: Vec<String>,
+    pub pack_instructions: Option<String>,
+    pub mcp_config: Option<String>,
+    // hooks_config is intentionally omitted: hooks are a runtime concern loaded
+    // separately by sprout-acp, not a pack-parsing concern.
+    /// Raw defaults block.
+    pub defaults: Option<serde_json::Value>,
+}
+
+// ── Entry point ───────────────────────────────────────────────────────────────
+
+/// Load a persona pack from a directory.
+///
+/// 1. Read and parse `.plugin/plugin.json`
+/// 2. For each persona path in the manifest, read the `.persona.md` file
+/// 3. Apply pack defaults to each persona (via merge logic)
+/// 4. Read `pack_instructions` if present
+/// 5. Read `.mcp.json` if present
+/// 6. Validate all paths resolve within `pack_root` (no path traversal)
+pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
+    let pack_root = pack_dir
+        .canonicalize()
+        .map_err(|e| PackError::Io { path: pack_dir.to_path_buf(), source: e })?;
+
+    // 1. Manifest
+    let manifest_path = pack_root.join(".plugin").join("plugin.json");
+    if !manifest_path.exists() {
+        return Err(PackError::ManifestNotFound(manifest_path));
+    }
+    let manifest_raw = read_file(&manifest_path)?;
+    let pm = manifest::parse_manifest(&manifest_raw)?;
+    let manifest = PackManifestData {
+        id: pm.id,
+        name: pm.name,
+        version: pm.version,
+        personas: pm.personas,
+        pack_instructions: pm.pack_instructions,
+        mcp_config: pm.mcp_config,
+        defaults: pm
+            .defaults
+            .map(|d| serde_json::to_value(d))
+            .transpose()
+            .map_err(|e| PackError::ManifestParse(format!("failed to serialize defaults: {e}")))?,
+    };
+
+    let pack_defaults = manifest.defaults.clone();
+
+    // 2 & 3. Personas
+    let mut personas = Vec::with_capacity(manifest.personas.len());
+    for rel_path in &manifest.personas {
+        let abs_path = safe_resolve(&pack_root, rel_path)?;
+        if !abs_path.exists() {
+            return Err(PackError::PersonaNotFound(abs_path));
+        }
+        let file_len = std::fs::metadata(&abs_path)
+            .map_err(|e| PackError::PersonaParse {
+                path: abs_path.clone(),
+                reason: format!("cannot stat file: {e}"),
+            })?
+            .len();
+        let size_limit = (persona::MAX_FRONTMATTER_BYTES + persona::MAX_BODY_BYTES + 200) as u64;
+        if file_len > size_limit {
+            return Err(PackError::PersonaParse {
+                path: abs_path,
+                reason: format!("file too large: {file_len} bytes (max {size_limit})"),
+            });
+        }
+        let content = read_file(&abs_path)?;
+        let persona = parse_persona_file(&abs_path, &content, pack_defaults.as_ref())?;
+        personas.push(persona);
+    }
+
+    // 4. Pack instructions
+    // Size limit for text files (instructions, mcp config): generous but bounded.
+    let text_size_limit = (crate::persona::MAX_FRONTMATTER_BYTES + crate::persona::MAX_BODY_BYTES + 200) as u64;
+
+    let pack_instructions = match &manifest.pack_instructions {
+        Some(rel) => {
+            let abs = safe_resolve(&pack_root, rel)?;
+            if !abs.exists() {
+                return Err(PackError::PersonaParse {
+                    path: abs,
+                    reason: format!("pack_instructions file not found: {rel}"),
+                });
+            }
+            let meta = std::fs::metadata(&abs)
+                .map_err(|e| PackError::PersonaParse {
+                    path: abs.clone(),
+                    reason: format!("cannot stat file: {e}"),
+                })?;
+            if meta.len() > text_size_limit {
+                return Err(PackError::PersonaParse {
+                    path: abs,
+                    reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                });
+            }
+            Some(read_file(&abs)?)
+        }
+        None => {
+            // Conventional fallback — missing is fine when not explicitly declared.
+            let path = pack_root.join("instructions.md");
+            if path.exists() {
+                let meta = std::fs::metadata(&path)
+                    .map_err(|e| PackError::PersonaParse {
+                        path: path.clone(),
+                        reason: format!("cannot stat file: {e}"),
+                    })?;
+                if meta.len() > text_size_limit {
+                    return Err(PackError::PersonaParse {
+                        path,
+                        reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                    });
+                }
+                Some(read_file(&path)?)
+            } else {
+                None
+            }
+        }
+    };
+
+    // 5. Shared MCP config
+    let parse_mcp = |raw: String, path: &Path| -> Result<serde_json::Value, PackError> {
+        serde_json::from_str(&raw).map_err(|e| PackError::McpConfigParse {
+            path: path.to_path_buf(),
+            reason: e.to_string(),
+        })
+    };
+    let shared_mcp_config = match &manifest.mcp_config {
+        Some(rel) => {
+            let abs = safe_resolve(&pack_root, rel)?;
+            if !abs.exists() {
+                return Err(PackError::PersonaParse {
+                    path: abs,
+                    reason: format!("mcp_config file not found: {rel}"),
+                });
+            }
+            let meta = std::fs::metadata(&abs)
+                .map_err(|e| PackError::PersonaParse {
+                    path: abs.clone(),
+                    reason: format!("cannot stat file: {e}"),
+                })?;
+            if meta.len() > text_size_limit {
+                return Err(PackError::PersonaParse {
+                    path: abs.clone(),
+                    reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                });
+            }
+            Some(parse_mcp(read_file(&abs)?, &abs)?)
+        }
+        None => {
+            // Conventional fallback — missing is fine when not explicitly declared.
+            let path = pack_root.join(".mcp.json");
+            if path.exists() {
+                let meta = std::fs::metadata(&path)
+                    .map_err(|e| PackError::PersonaParse {
+                        path: path.clone(),
+                        reason: format!("cannot stat file: {e}"),
+                    })?;
+                if meta.len() > text_size_limit {
+                    return Err(PackError::PersonaParse {
+                        path,
+                        reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                    });
+                }
+                Some(parse_mcp(read_file(&path)?, &path)?)
+            } else {
+                None
+            }
+        }
+    };
+
+    // 6. Skills directory
+    let skills_dir = {
+        let path = pack_root.join("skills");
+        if path.is_dir() { Some(path) } else { None }
+    };
+
+    Ok(LoadedPack {
+        manifest,
+        personas,
+        pack_instructions,
+        shared_mcp_config,
+        skills_dir,
+    })
+}
+
+// ── Skill resolution ──────────────────────────────────────────────────────────
+
+/// Determine which skills go to which persona.
+///
+/// - Skills listed in a persona's `skills:` array → only that persona
+/// - Skills not listed in any persona's `skills:` → all personas (shared)
+///
+/// Returns a map of `persona_name → Vec<skill_name>`.
+pub fn resolve_skills(
+    pack_dir: &Path,
+    personas: &[LoadedPersona],
+) -> HashMap<String, Vec<String>> {
+    // Normalize a persona skill path (e.g. `"./skills/security-review/"`,
+    // `"skills/search"`, `"web-search"`) to just the final path component
+    // so it can be compared against bare `read_dir` entry names.
+    fn normalize_skill_name(path: &str) -> String {
+        std::path::Path::new(path.trim_end_matches('/'))
+            .file_name()
+            .and_then(|n| n.to_str())
+            .unwrap_or(path)
+            .to_owned()
+    }
+
+    // Collect all *normalized* skill names claimed by at least one persona.
+    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
+    for p in personas {
+        for s in &p.skills {
+            claimed.insert(normalize_skill_name(s));
+        }
+    }
+
+    // Enumerate skills directory — directories only, skip dotfiles.
+    let skills_path = pack_dir.join("skills");
+    let all_skills: Vec<String> = if skills_path.is_dir() {
+        std::fs::read_dir(&skills_path)
+            .into_iter()
+            .flatten()
+            .flatten()
+            .filter_map(|entry| {
+                let name = entry.file_name().to_string_lossy().into_owned();
+                // Skip dotfiles
+                if name.starts_with('.') {
+                    return None;
+                }
+                // Directories only (skip plain files and unresolvable entries)
+                if entry.file_type().ok()?.is_dir() {
+                    Some(name)
+                } else {
+                    None
+                }
+            })
+            .collect()
+    } else {
+        vec![]
+    };
+
+    // Shared skills = those not claimed by any persona.
+    let shared: Vec<String> = all_skills
+        .iter()
+        .filter(|s| !claimed.contains(*s))
+        .cloned()
+        .collect();
+
+    let mut result: HashMap<String, Vec<String>> = HashMap::new();
+    for p in personas {
+        // Normalize claimed skill paths to bare directory names so the output
+        // format is consistent with shared skills (which come from read_dir).
+        let mut skills: Vec<String> = p.skills.iter().map(|s| normalize_skill_name(s)).collect();
+        for s in &shared {
+            if !skills.contains(s) {
+                skills.push(s.clone());
+            }
+        }
+        result.insert(p.name.clone(), skills);
+    }
+
+    result
+}
+
+// ── Path safety ───────────────────────────────────────────────────────────────
+
+/// Verify a path resolves within the pack root.
+///
+/// Defense-in-depth:
+/// 1. Reject any `..` path component before canonicalization.
+/// 2. Canonicalize the joined path.
+/// 3. Verify the result has `pack_root` as a prefix.
+fn safe_resolve(pack_root: &Path, relative: &str) -> Result<PathBuf, PackError> {
+    // Step 0: reject absolute paths (Unix `/` prefix or Windows drive letters).
+    if relative.starts_with('/') {
+        return Err(PackError::PathTraversal(relative.to_owned()));
+    }
+    #[cfg(windows)]
+    if relative.len() >= 2 && relative.as_bytes()[1] == b':' {
+        return Err(PackError::PathTraversal(relative.to_owned()));
+    }
+
+    // Step 1: reject `..` components eagerly.
+    let rel = Path::new(relative);
+    for component in rel.components() {
+        if component == Component::ParentDir {
+            return Err(PackError::PathTraversal(relative.to_owned()));
+        }
+    }
+
+    let joined = pack_root.join(rel);
+
+    // Step 2: canonicalize (resolves symlinks).
+    // If the path doesn't exist yet we can't canonicalize — return the
+    // un-canonicalized path so callers can produce a proper "not found" error.
+    // We still verify the non-canonical form doesn't escape (belt-and-suspenders).
+    if !joined.exists() {
+        // Normalize without canonicalize: just check the lexical prefix.
+        // The `..` check above already guards against traversal.
+        return Ok(joined);
+    }
+
+    let canonical = joined
+        .canonicalize()
+        .map_err(|e| PackError::Io { path: joined.clone(), source: e })?;
+
+    // Step 3: must be inside pack_root.
+    if !canonical.starts_with(pack_root) {
+        return Err(PackError::PathEscape(canonical));
+    }
+
+    Ok(canonical)
+}
+
+// ── Parsing helpers ───────────────────────────────────────────────────────────
+
+fn read_file(path: &Path) -> Result<String, PackError> {
+    std::fs::read_to_string(path).map_err(|e| PackError::Io { path: path.to_path_buf(), source: e })
+}
+
+/// Parse a `.persona.md` file.
+///
+/// Delegates identity and prompt parsing to [`persona::parse_persona_md`],
+/// then applies pack-level behavioral defaults via the merge layer.
+fn parse_persona_file(
+    path: &Path,
+    content: &str,
+    pack_defaults: Option<&serde_json::Value>,
+) -> Result<LoadedPersona, PackError> {
+    let pc: PersonaConfig =
+        persona::parse_persona_md(content).map_err(|e| PackError::PersonaParse {
+            path: path.to_path_buf(),
+            reason: e.to_string(),
+        })?;
+
+    // Serialize the behavioral fields of PersonaConfig to JSON so the
+    // existing merge logic can do precedence resolution unchanged.
+    let fm_json = serde_json::to_value(&pc).map_err(|e| PackError::PersonaParse {
+        path: path.to_path_buf(),
+        reason: e.to_string(),
+    })?;
+
+    let resolved = resolve_persona_config(&fm_json, pack_defaults);
+
+    // Convert typed MCP server configs back to raw JSON values.
+    let mcp_servers: Vec<serde_json::Value> = pc
+        .mcp_servers
+        .iter()
+        .filter_map(|s| serde_json::to_value(s).ok())
+        .collect();
+
+    // Convert typed Hooks to HooksData.
+    let hooks = pc.hooks.map(|h| HooksData {
+        on_start: h.on_start,
+        on_stop: h.on_stop,
+        on_message: h.on_message,
+    });
+
+    Ok(LoadedPersona {
+        source_path: path.to_path_buf(),
+        name: pc.name,
+        display_name: pc.display_name,
+        description: pc.description,
+        avatar: pc.avatar,
+        model: resolved.model,
+        temperature: resolved.temperature,
+        max_context_tokens: resolved.max_context_tokens,
+        subscribe: resolved.subscribe.unwrap_or_default(),
+        triggers: resolved.triggers,
+        thread_replies: resolved.thread_replies,
+        broadcast_replies: resolved.broadcast_replies,
+        skills: pc.skills,
+        mcp_servers,
+        hooks,
+        prompt: pc.prompt,
+    })
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use tempfile::TempDir;
+
+    // ── Fixture helpers ───────────────────────────────────────────────────────
+
+    fn make_pack(dir: &TempDir, personas: &[(&str, &str)]) -> PathBuf {
+        let root = dir.path();
+
+        // .plugin/plugin.json
+        fs::create_dir_all(root.join(".plugin")).unwrap();
+        let persona_paths: Vec<String> = personas
+            .iter()
+            .map(|(name, _)| format!("personas/{name}.persona.md"))
+            .collect();
+        let manifest = serde_json::json!({
+            "id": "test-pack",
+            "name": "Test Pack",
+            "version": "0.1.0",
+            "personas": persona_paths,
+        });
+        fs::write(
+            root.join(".plugin/plugin.json"),
+            serde_json::to_string_pretty(&manifest).unwrap(),
+        )
+        .unwrap();
+
+        // personas/
+        fs::create_dir_all(root.join("personas")).unwrap();
+        for (name, content) in personas {
+            fs::write(root.join(format!("personas/{name}.persona.md")), content).unwrap();
+        }
+
+        root.to_path_buf()
+    }
+
+    const SIMPLE_PERSONA: &str = r#"---
+name: berry
+display_name: Berry
+description: A fast worker
+---
+You are Berry, a fast and direct worker.
+"#;
+
+    // ── load_pack: happy path ─────────────────────────────────────────────────
+
+    #[test]
+    fn load_valid_pack() {
+        let dir = TempDir::new().unwrap();
+        let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
+        let pack = load_pack(&root).unwrap();
+
+        assert_eq!(pack.manifest.id, "test-pack");
+        assert_eq!(pack.personas.len(), 1);
+        assert_eq!(pack.personas[0].name, "berry");
+        assert_eq!(pack.personas[0].display_name, "Berry");
+        assert!(pack.personas[0].prompt.contains("fast and direct"));
+        // Built-in defaults
+        assert!(pack.personas[0].thread_replies);
+        assert!(!pack.personas[0].broadcast_replies);
+    }
+
+    #[test]
+    fn load_pack_with_instructions_and_mcp() {
+        let dir = TempDir::new().unwrap();
+        let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
+
+        fs::write(root.join("instructions.md"), "Pack-level instructions.").unwrap();
+        fs::write(
+            root.join(".mcp.json"),
+            r#"{"mcpServers": {}}"#,
+        )
+        .unwrap();
+
+        let pack = load_pack(&root).unwrap();
+        assert_eq!(pack.pack_instructions.as_deref(), Some("Pack-level instructions."));
+        assert!(pack.shared_mcp_config.is_some());
+    }
+
+    #[test]
+    fn load_pack_skills_dir_detected() {
+        let dir = TempDir::new().unwrap();
+        let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
+        fs::create_dir_all(root.join("skills")).unwrap();
+
+        let pack = load_pack(&root).unwrap();
+        assert!(pack.skills_dir.is_some());
+    }
+
+    // ── load_pack: error cases ────────────────────────────────────────────────
+
+    #[test]
+    fn missing_plugin_json_returns_error() {
+        let dir = TempDir::new().unwrap();
+        let err = load_pack(dir.path()).unwrap_err();
+        assert!(matches!(err, PackError::ManifestNotFound(_)));
+    }
+
+    #[test]
+    fn missing_persona_file_returns_error() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path();
+
+        fs::create_dir_all(root.join(".plugin")).unwrap();
+        let manifest = serde_json::json!({
+            "id": "x", "name": "X", "version": "0.1.0",
+            "personas": ["personas/ghost.persona.md"],
+        });
+        fs::write(
+            root.join(".plugin/plugin.json"),
+            serde_json::to_string(&manifest).unwrap(),
+        )
+        .unwrap();
+        fs::create_dir_all(root.join("personas")).unwrap();
+
+        let err = load_pack(root).unwrap_err();
+        assert!(matches!(err, PackError::PersonaNotFound(_)));
+    }
+
+    // ── Path safety ───────────────────────────────────────────────────────────
+
+    #[test]
+    fn dotdot_component_rejected() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path().canonicalize().unwrap();
+        let err = safe_resolve(&root, "../../etc/passwd").unwrap_err();
+        assert!(matches!(err, PackError::PathTraversal(_)));
+    }
+
+    #[test]
+    fn dotdot_in_middle_rejected() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path().canonicalize().unwrap();
+        let err = safe_resolve(&root, "personas/../../../etc/passwd").unwrap_err();
+        assert!(matches!(err, PackError::PathTraversal(_)));
+    }
+
+    #[test]
+    fn normal_path_resolves_ok() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path().canonicalize().unwrap();
+        fs::create_dir_all(root.join("personas")).unwrap();
+        let target = root.join("personas/berry.persona.md");
+        fs::write(&target, "hello").unwrap();
+        let resolved = safe_resolve(&root, "personas/berry.persona.md").unwrap();
+        assert_eq!(resolved, target.canonicalize().unwrap());
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn symlink_escape_rejected() {
+        use std::os::unix::fs::symlink;
+
+        let dir = TempDir::new().unwrap();
+        let root = dir.path().canonicalize().unwrap();
+        fs::create_dir_all(root.join("personas")).unwrap();
+
+        // Create a symlink inside the pack that points outside.
+        let outside = TempDir::new().unwrap();
+        let outside_file = outside.path().join("secret.txt");
+        fs::write(&outside_file, "secret").unwrap();
+
+        symlink(&outside_file, root.join("personas/escape.persona.md")).unwrap();
+
+        let err = safe_resolve(&root, "personas/escape.persona.md").unwrap_err();
+        assert!(matches!(err, PackError::PathEscape(_)));
+    }
+
+    // ── Skill resolution ──────────────────────────────────────────────────────
+
+    fn make_loaded_persona(name: &str, skills: Vec<&str>) -> LoadedPersona {
+        LoadedPersona {
+            source_path: PathBuf::from(format!("{name}.persona.md")),
+            name: name.to_owned(),
+            display_name: name.to_owned(),
+            description: String::new(),
+            avatar: None,
+            model: None,
+            temperature: None,
+            max_context_tokens: None,
+            subscribe: vec![],
+            triggers: None,
+            thread_replies: true,
+            broadcast_replies: false,
+            skills: skills.into_iter().map(str::to_owned).collect(),
+            mcp_servers: vec![],
+            hooks: None,
+            prompt: String::new(),
+        }
+    }
+
+    #[test]
+    fn listed_skills_go_to_specific_persona() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path();
+        let skills_dir = root.join("skills");
+        fs::create_dir_all(&skills_dir).unwrap();
+        fs::create_dir_all(skills_dir.join("web-search")).unwrap();
+        fs::create_dir_all(skills_dir.join("code-review")).unwrap();
+
+        let personas = vec![
+            make_loaded_persona("alpha", vec!["web-search"]),
+            make_loaded_persona("beta", vec![]),
+        ];
+
+        let map = resolve_skills(root, &personas);
+
+        // alpha claimed "web-search" → only alpha gets it
+        assert!(map["alpha"].contains(&"web-search".to_owned()));
+        // "code-review" is unclaimed → both get it
+        assert!(map["alpha"].contains(&"code-review".to_owned()));
+        assert!(map["beta"].contains(&"code-review".to_owned()));
+        // beta did NOT claim "web-search" and it was claimed → beta doesn't get it
+        assert!(!map["beta"].contains(&"web-search".to_owned()));
+    }
+
+    #[test]
+    fn unclaimed_skills_go_to_all_personas() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path();
+        let skills_dir = root.join("skills");
+        fs::create_dir_all(&skills_dir).unwrap();
+        fs::create_dir_all(skills_dir.join("shared-skill")).unwrap();
+
+        let personas = vec![
+            make_loaded_persona("alpha", vec![]),
+            make_loaded_persona("beta", vec![]),
+        ];
+
+        let map = resolve_skills(root, &personas);
+        assert!(map["alpha"].contains(&"shared-skill".to_owned()));
+        assert!(map["beta"].contains(&"shared-skill".to_owned()));
+    }
+
+    #[test]
+    fn no_skills_dir_returns_empty() {
+        let dir = TempDir::new().unwrap();
+        let personas = vec![make_loaded_persona("alpha", vec![])];
+        let map = resolve_skills(dir.path(), &personas);
+        assert!(map["alpha"].is_empty());
+    }
+
+    #[test]
+    fn claimed_skill_paths_normalized_to_bare_names() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path();
+        let skills_dir = root.join("skills");
+        fs::create_dir_all(&skills_dir).unwrap();
+        fs::create_dir_all(skills_dir.join("web-search")).unwrap();
+
+        // Persona claims skill via a path with prefix and trailing slash.
+        let personas = vec![make_loaded_persona("alpha", vec!["./skills/web-search/"])];
+
+        let map = resolve_skills(root, &personas);
+
+        // Output must be the bare name, not the raw path.
+        assert!(map["alpha"].contains(&"web-search".to_owned()));
+        assert!(!map["alpha"].iter().any(|s| s.contains('/')));
+    }
+
+    // ── Pack defaults ─────────────────────────────────────────────────────────
+
+    #[test]
+    fn pack_defaults_applied_to_persona() {
+        let dir = TempDir::new().unwrap();
+        let root = dir.path();
+
+        fs::create_dir_all(root.join(".plugin")).unwrap();
+        let manifest = serde_json::json!({
+            "id": "test-pack",
+            "name": "Test Pack",
+            "version": "0.1.0",
+            "personas": ["personas/berry.persona.md"],
+            "defaults": {
+                "model": "claude-3-sonnet",
+                "thread_replies": false,
+            }
+        });
+        fs::write(
+            root.join(".plugin/plugin.json"),
+            serde_json::to_string_pretty(&manifest).unwrap(),
+        )
+        .unwrap();
+
+        fs::create_dir_all(root.join("personas")).unwrap();
+        // Persona does NOT set model or thread_replies → should inherit defaults
+        fs::write(
+            root.join("personas/berry.persona.md"),
+            "---\nname: berry\ndisplay_name: Berry\ndescription: Fast\n---\nYou are Berry.\n",
+        )
+        .unwrap();
+
+        let pack = load_pack(root).unwrap();
+        let p = &pack.personas[0];
+        assert_eq!(p.model.as_deref(), Some("claude-3-sonnet"));
+        assert!(!p.thread_replies);
+    }
+}
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 53a60f875f..1b5c9c6f13 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -13,7 +13,7 @@
 use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 
-use crate::merge::RespondToData;
+use crate::merge::TriggersData;
 use crate::pack::{self, LoadedPack, LoadedPersona, PackError};
 use crate::persona::split_model;
 
@@ -72,8 +72,8 @@ pub struct ResolvedMcpServer {
 /// Lifecycle hooks (pack-relative paths).
 #[derive(Debug, Clone, PartialEq)]
 pub struct ResolvedHooks {
-    pub on_start: Option<PathBuf>,
-    pub on_stop: Option<PathBuf>,
+    pub on_start: Option<String>,
+    pub on_stop: Option<String>,
 }
 
 /// What triggers a response (renamed from respond_to per spec discussion).
@@ -104,22 +104,19 @@ pub struct ResolvedPack {
 /// Goose env vars are projected from model/temperature/context config.
 pub fn resolve_pack(pack_dir: &Path) -> Result<ResolvedPack, PackError> {
     let loaded = pack::load_pack(pack_dir)?;
-    resolve_loaded_pack(&loaded, pack_dir)
+    resolve_loaded_pack(&loaded)
 }
 
 /// Resolve from an already-loaded pack. Useful when you've already called
 /// `load_pack()` and want to avoid re-reading the filesystem.
-pub fn resolve_loaded_pack(
-    loaded: &LoadedPack,
-    pack_dir: &Path,
-) -> Result<ResolvedPack, PackError> {
+pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackError> {
     let pack_version = &loaded.manifest.version;
     let pack_instructions = loaded.pack_instructions.as_deref();
     let shared_mcp = loaded.shared_mcp_config.as_ref();
 
     let mut personas = Vec::with_capacity(loaded.personas.len());
     for lp in &loaded.personas {
-        personas.push(resolve_one_persona(lp, pack_version, pack_instructions, shared_mcp, pack_dir));
+        personas.push(resolve_one_persona(lp, pack_version, pack_instructions, shared_mcp));
     }
 
     Ok(ResolvedPack {
@@ -151,7 +148,6 @@ fn resolve_one_persona(
     pack_version: &str,
     pack_instructions: Option<&str>,
     shared_mcp: Option<&serde_json::Value>,
-    pack_dir: &Path,
 ) -> ResolvedPersona {
     let system_prompt = compose_prompt(&lp.prompt, pack_instructions);
 
@@ -167,9 +163,9 @@ fn resolve_one_persona(
         _ => (None, None),
     };
 
-    let triggers = resolve_triggers(lp.respond_to.as_ref());
+    let triggers = resolve_triggers(lp.triggers.as_ref());
     let mcp_servers = merge_mcp_servers(shared_mcp, &lp.mcp_servers);
-    let hooks = resolve_hooks(lp.hooks.as_ref(), pack_dir);
+    let hooks = resolve_hooks(lp.hooks.as_ref());
     let goose_env_vars = project_env_vars(lp);
 
     // Version: pack version (LoadedPersona doesn't carry a per-persona version).
@@ -211,8 +207,8 @@ fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> Stri
 
 // ── Triggers resolution ───────────────────────────────────────────────────────
 
-/// Convert `RespondToData` to `ResolvedTriggers` (renamed per spec discussion).
-fn resolve_triggers(rt: Option<&RespondToData>) -> ResolvedTriggers {
+/// Convert `TriggersData` to `ResolvedTriggers`.
+fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers {
     match rt {
         Some(data) => ResolvedTriggers {
             mentions: data.mentions,
@@ -304,19 +300,21 @@ fn parse_mcp_server_config(name: &str, config: &serde_json::Value) -> Option<Res
 
 // ── Hooks resolution ──────────────────────────────────────────────────────────
 
-/// Resolve hooks to absolute paths (pack-relative → absolute).
-fn resolve_hooks(
-    hooks: Option<&crate::merge::HooksData>,
-    pack_dir: &Path,
-) -> Option<ResolvedHooks> {
+/// Store hook paths as raw relative strings (no path resolution).
+///
+/// Security: we intentionally do NOT resolve these to absolute paths.
+/// Hook paths come from untrusted persona frontmatter and could contain
+/// `../` traversal. Since hooks are not executed in this PR, we store
+/// them as-is. The PR that wires execution MUST validate through
+/// `safe_resolve()` before use.
+fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option<ResolvedHooks> {
     let h = hooks?;
-    // Only return Some if at least one hook is set.
     if h.on_start.is_none() && h.on_stop.is_none() {
         return None;
     }
     Some(ResolvedHooks {
-        on_start: h.on_start.as_ref().map(|p| pack_dir.join(p)),
-        on_stop: h.on_stop.as_ref().map(|p| pack_dir.join(p)),
+        on_start: h.on_start.clone(),
+        on_stop: h.on_stop.clone(),
     })
 }
 
@@ -371,7 +369,7 @@ fn project_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::merge::HooksData;
+    use crate::merge::{HooksData, TriggersData};
 
     // ── compose_prompt ────────────────────────────────────────────────────
 
@@ -398,8 +396,8 @@ mod tests {
     // ── resolve_triggers ──────────────────────────────────────────────────
 
     #[test]
-    fn triggers_from_respond_to_data() {
-        let data = RespondToData {
+    fn triggers_from_triggers_data() {
+        let data = TriggersData {
             mentions: false,
             keywords: vec!["security".into(), "CVE".into()],
             all_messages: false,
@@ -503,16 +501,17 @@ mod tests {
     // ── resolve_hooks ─────────────────────────────────────────────────────
 
     #[test]
-    fn hooks_resolved_to_absolute_paths() {
+    fn hooks_stored_as_raw_relative_paths() {
+        // Security: hooks are stored as raw strings, NOT resolved to absolute.
+        // Path traversal validation deferred to the PR that wires execution.
         let data = HooksData {
             on_start: Some("hooks/start.sh".into()),
             on_stop: Some("hooks/stop.sh".into()),
             on_message: None,
         };
-        let pack_dir = Path::new("/packs/my-pack");
-        let h = resolve_hooks(Some(&data), pack_dir).unwrap();
-        assert_eq!(h.on_start.unwrap(), PathBuf::from("/packs/my-pack/hooks/start.sh"));
-        assert_eq!(h.on_stop.unwrap(), PathBuf::from("/packs/my-pack/hooks/stop.sh"));
+        let h = resolve_hooks(Some(&data)).unwrap();
+        assert_eq!(h.on_start.as_deref(), Some("hooks/start.sh"));
+        assert_eq!(h.on_stop.as_deref(), Some("hooks/stop.sh"));
     }
 
     #[test]
@@ -522,12 +521,12 @@ mod tests {
             on_stop: None,
             on_message: None,
         };
-        assert!(resolve_hooks(Some(&data), Path::new("/x")).is_none());
+        assert!(resolve_hooks(Some(&data)).is_none());
     }
 
     #[test]
     fn hooks_none_when_absent() {
-        assert!(resolve_hooks(None, Path::new("/x")).is_none());
+        assert!(resolve_hooks(None).is_none());
     }
 
     // ── project_env_vars ──────────────────────────────────────────────────
@@ -850,7 +849,7 @@ mod tests {
             temperature,
             max_context_tokens,
             subscribe: vec![],
-            respond_to: None,
+            triggers: None,
             thread_replies: true,
             broadcast_replies: false,
             skills: vec![],

From c9dc094b05af9de18f1fbbecd96ee45f01efa58b Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:40:59 -0400
Subject: [PATCH 07/28] =?UTF-8?q?feat(acp+desktop):=20persona=20pack=20int?=
 =?UTF-8?q?egration=20=E2=80=94=20CLI=20flags,=20env=20injection,=20runtim?=
 =?UTF-8?q?e=20bridge?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ACP changes:
- Cargo.toml: add sprout-persona dependency
- config.rs: add --persona-pack and --persona-name CLI flags (with env var
  fallbacks SPROUT_ACP_PERSONA_PACK, SPROUT_ACP_PERSONA_NAME)
- config.rs: resolve persona at startup, apply precedence rule
  (CLI/env > persona > built-in defaults) for system_prompt and model
- config.rs: store persona_env_vars on Config for spawn-time injection
- acp.rs: extend AcpClient::spawn() with extra_env parameter; only inject
  vars not already set in parent env (operator precedence)
- main.rs: thread persona_env_vars through all spawn_and_init call sites

Desktop changes:
- runtime.rs: for pack-backed agents (persona_pack_path set), set
  SPROUT_ACP_PERSONA_PACK and SPROUT_ACP_PERSONA_NAME env vars and
  skip SPROUT_ACP_SYSTEM_PROMPT/MODEL (let ACP resolve from pack).
  For non-pack agents, existing behavior unchanged.

Precedence rule: CLI/env args > persona values > built-in defaults.
Persona fills in what's missing. Explicit flags always win.
---
 crates/sprout-acp/Cargo.toml                  |  1 +
 crates/sprout-acp/src/acp.rs                  | 16 ++++-
 crates/sprout-acp/src/config.rs               | 66 ++++++++++++++++++-
 crates/sprout-acp/src/main.rs                 | 27 ++++++--
 .../src-tauri/src/managed_agents/runtime.rs   | 26 ++++++--
 5 files changed, 118 insertions(+), 18 deletions(-)

diff --git a/crates/sprout-acp/Cargo.toml b/crates/sprout-acp/Cargo.toml
index 869e4eea48..d03079f1d1 100644
--- a/crates/sprout-acp/Cargo.toml
+++ b/crates/sprout-acp/Cargo.toml
@@ -15,6 +15,7 @@ path = "src/main.rs"
 # Internal
 sprout-core = { workspace = true }
 sprout-sdk = { workspace = true }
+sprout-persona = { path = "../sprout-persona" }
 
 # Nostr
 nostr = { workspace = true }
diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs
index 198228c743..e9b2999400 100644
--- a/crates/sprout-acp/src/acp.rs
+++ b/crates/sprout-acp/src/acp.rs
@@ -174,7 +174,11 @@ impl AcpClient {
     /// Spawn the agent binary as a subprocess and connect to its stdio pipes.
     ///
     /// After spawning, call [`initialize`](Self::initialize) before any other method.
-    pub async fn spawn(command: &str, args: &[String]) -> Result<Self, AcpError> {
+    pub async fn spawn(
+        command: &str,
+        args: &[String],
+        extra_env: &[(String, String)],
+    ) -> Result<Self, AcpError> {
         use std::process::Stdio;
 
         let mut cmd = tokio::process::Command::new(command);
@@ -187,6 +191,14 @@ impl AcpClient {
             // Callers MUST still call shutdown().await for guaranteed cleanup.
             .kill_on_drop(true);
 
+        // Per-persona env vars (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
+        // Only injected if not already set in parent env (operator precedence).
+        for (key, value) in extra_env {
+            if std::env::var(key).is_err() {
+                cmd.env(key, value);
+            }
+        }
+
         // Spawn the agent in its own process group so SIGKILL doesn't propagate
         // to the harness's own process group on Unix.
         // tokio::process::Command::process_group is a stable tokio API (no extra imports needed).
@@ -1614,7 +1626,7 @@ mod tests {
     // ── Async integration tests with real subprocess ──────────────────────
 
     async fn spawn_script(script: &str) -> AcpClient {
-        AcpClient::spawn("bash", &["-c".into(), script.into()])
+        AcpClient::spawn("bash", &["-c".into(), script.into()], &[])
             .await
             .expect("failed to spawn test script")
     }
diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs
index 613edca9fb..368bc99a0b 100644
--- a/crates/sprout-acp/src/config.rs
+++ b/crates/sprout-acp/src/config.rs
@@ -349,6 +349,15 @@ pub struct CliArgs {
     /// Owner pubkey is always implicitly included.
     #[arg(long, env = "SPROUT_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')]
     pub respond_to_allowlist: Option<Vec<String>>,
+
+    /// Path to a persona pack directory. Used with --persona-name to configure
+    /// the agent from a .persona.md pack instead of CLI flags.
+    #[arg(long, env = "SPROUT_ACP_PERSONA_PACK")]
+    pub persona_pack: Option<PathBuf>,
+
+    /// Name of the persona within the pack to use. Required when --persona-pack is set.
+    #[arg(long, env = "SPROUT_ACP_PERSONA_NAME")]
+    pub persona_name: Option<String>,
 }
 
 // ── Merged NIP-01 filter ──────────────────────────────────────────────────────
@@ -400,6 +409,9 @@ pub struct Config {
     pub respond_to: RespondTo,
     /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist).
     pub respond_to_allowlist: HashSet<String>,
+    /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL).
+    /// Populated from persona pack resolution. Empty when no pack is configured.
+    pub persona_env_vars: Vec<(String, String)>,
 }
 
 /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
@@ -507,7 +519,7 @@ impl Config {
             .replace_range(.., &"0".repeat(args.private_key.len()));
         args.private_key.clear();
 
-        let system_prompt = if let Some(text) = args.system_prompt {
+        let mut system_prompt = if let Some(text) = args.system_prompt {
             Some(text)
         } else if let Some(ref path) = args.system_prompt_file {
             Some(std::fs::read_to_string(path)?)
@@ -642,6 +654,54 @@ impl Config {
             HashSet::new()
         };
 
+        // ── Persona pack resolution ──────────────────────────────────────────
+        //
+        // Precedence: CLI/env args > persona values > built-in defaults.
+        // Persona fills in what's missing. Explicit flags always win.
+        let (persona_system_prompt, persona_model, persona_env_vars) =
+            match (&args.persona_pack, &args.persona_name) {
+                (Some(pack_dir), Some(name)) => {
+                    let pack = sprout_persona::resolve::resolve_pack(pack_dir).map_err(|e| {
+                        ConfigError::ConfigFile(format!(
+                            "failed to resolve pack {}: {e}",
+                            pack_dir.display()
+                        ))
+                    })?;
+                    let persona = pack
+                        .personas
+                        .into_iter()
+                        .find(|p| p.name == *name)
+                        .ok_or_else(|| {
+                            ConfigError::ConfigFile(format!(
+                                "persona '{name}' not found in pack {}",
+                                pack_dir.display()
+                            ))
+                        })?;
+                    (
+                        Some(persona.system_prompt),
+                        persona.model,
+                        persona.goose_env_vars,
+                    )
+                }
+                (Some(_), None) => {
+                    return Err(ConfigError::ConfigFile(
+                        "--persona-pack requires --persona-name".into(),
+                    ));
+                }
+                (None, Some(_)) => {
+                    return Err(ConfigError::ConfigFile(
+                        "--persona-name requires --persona-pack".into(),
+                    ));
+                }
+                (None, None) => (None, None, vec![]),
+            };
+
+        // Apply persona defaults: CLI/env wins, persona fills gaps.
+        if system_prompt.is_none() {
+            system_prompt = persona_system_prompt;
+        }
+        let model = args.model.or(persona_model);
+
         // ── Multiple-event-handling validation ──────────────────────────────
         if matches!(
             args.multiple_event_handling,
@@ -682,10 +742,11 @@ impl Config {
             max_turns_per_session: args.max_turns_per_session,
             presence_enabled: !args.no_presence,
             typing_enabled: !args.no_typing,
-            model: args.model,
+            model,
             permission_mode: args.permission_mode,
             respond_to: args.respond_to,
             respond_to_allowlist,
+            persona_env_vars,
         };
 
         Ok(config)
@@ -1045,6 +1106,7 @@ mod tests {
             permission_mode: PermissionMode::BypassPermissions,
             respond_to: RespondTo::Anyone,
             respond_to_allowlist: HashSet::new(),
+            persona_env_vars: vec![],
         }
     }
 
diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs
index f906b5b713..27cf7cad72 100644
--- a/crates/sprout-acp/src/main.rs
+++ b/crates/sprout-acp/src/main.rs
@@ -541,7 +541,12 @@ async fn tokio_main() -> Result<()> {
         // This matches the run_models pattern and prevents zombie leaks on
         // init timeout (the cancelled future would drop the AcpClient via
         // Drop which is best-effort only).
-        let spawn_result = AcpClient::spawn(&config.agent_command, &config.agent_args).await;
+        let spawn_result = AcpClient::spawn(
+            &config.agent_command,
+            &config.agent_args,
+            &config.persona_env_vars,
+        )
+        .await;
         match spawn_result {
             Ok(mut acp) => {
                 match tokio::time::timeout(Duration::from_secs(60), acp.initialize()).await {
@@ -912,9 +917,10 @@ async fn tokio_main() -> Result<()> {
                 tracing::info!(agent = idx, "slot refill: spawning background respawn");
                 let cmd = config.agent_command.clone();
                 let args = config.agent_args.clone();
+                let env = config.persona_env_vars.clone();
                 let guard = RespawnGuard::new(idx, respawn_tx.clone());
                 respawn_tasks.spawn(async move {
-                    let result = spawn_and_init(&cmd, &args).await;
+                    let result = spawn_and_init(&cmd, &args, &env).await;
                     guard.send(result);
                 });
             }
@@ -1837,12 +1843,13 @@ fn recover_panicked_agent(
     slot.respawn_in_flight = true;
     let cmd = config.agent_command.clone();
     let args = config.agent_args.clone();
+    let env = config.persona_env_vars.clone();
     let guard = RespawnGuard::new(i, respawn_tx.clone());
     respawn_tasks.spawn(async move {
         if !delay.is_zero() {
             tokio::time::sleep(delay).await;
         }
-        let result = spawn_and_init(&cmd, &args).await;
+        let result = spawn_and_init(&cmd, &args, &env).await;
         guard.send(result);
     });
 }
@@ -1983,6 +1990,7 @@ fn spawn_respawn_task(
     // Spawn the actual work (shutdown + sleep + spawn + init) off the main loop.
     let cmd = config.agent_command.clone();
     let args = config.agent_args.clone();
+    let env = config.persona_env_vars.clone();
     let guard = RespawnGuard::new(index, respawn_tx.clone());
     respawn_tasks.spawn(async move {
         // Shutdown old agent (reap child, prevent zombie).
@@ -1994,7 +2002,7 @@ fn spawn_respawn_task(
             tokio::time::sleep(delay).await;
         }
 
-        let result = spawn_and_init(&cmd, &args).await;
+        let result = spawn_and_init(&cmd, &args, &env).await;
         guard.send(result);
     });
 
@@ -2007,8 +2015,12 @@ fn spawn_respawn_task(
 ///
 /// Takes owned args so it can run in a background `tokio::spawn` task without
 /// borrowing `Config`. All respawn/refill paths use this.
-async fn spawn_and_init(command: &str, args: &[String]) -> Result<AcpClient> {
-    let mut acp = AcpClient::spawn(command, args)
+async fn spawn_and_init(
+    command: &str,
+    args: &[String],
+    extra_env: &[(String, String)],
+) -> Result<AcpClient> {
+    let mut acp = AcpClient::spawn(command, args, extra_env)
         .await
         .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?;
 
@@ -2045,7 +2057,8 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
         .to_string();
 
     // Spawn outside the timeout so we always own the child for cleanup.
-    let mut client = match AcpClient::spawn(&args.agent_command, &agent_args).await {
+    // `models` subcommand doesn't use persona packs — no extra env.
+    let mut client = match AcpClient::spawn(&args.agent_command, &agent_args, &[]).await {
         Ok(c) => c,
         Err(e) => {
             eprintln!("error: failed to spawn agent: {e}");
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 37ad92772a..49e36c66c1 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -516,15 +516,27 @@ pub fn start_managed_agent_process(
         "GOOSE_MODE",
         std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
     );
-    if let Some(system_prompt) = &record.system_prompt {
-        command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
-    } else {
+    // For pack-backed agents, let ACP resolve config from the pack at startup.
+    // Do NOT set SPROUT_ACP_SYSTEM_PROMPT or SPROUT_ACP_MODEL — those would
+    // override persona values per the precedence rule (CLI/env > persona > default).
+    if let (Some(pack_path), Some(persona_name)) =
+        (&record.persona_pack_path, &record.persona_name_in_pack)
+    {
+        command.env("SPROUT_ACP_PERSONA_PACK", pack_path);
+        command.env("SPROUT_ACP_PERSONA_NAME", persona_name);
         command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
-    }
-    if let Some(model) = &record.model {
-        command.env("SPROUT_ACP_MODEL", model);
-    } else {
         command.env_remove("SPROUT_ACP_MODEL");
+    } else {
+        if let Some(system_prompt) = &record.system_prompt {
+            command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
+        } else {
+            command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
+        }
+        if let Some(model) = &record.model {
+            command.env("SPROUT_ACP_MODEL", model);
+        } else {
+            command.env_remove("SPROUT_ACP_MODEL");
+        }
     }
     command.env_remove("SPROUT_ACP_PRIVATE_KEY");
     command.env_remove("SPROUT_ACP_API_TOKEN");

From 4f7179b394b3e95cd60a5a7338b66c30525df690 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:46:27 -0400
Subject: [PATCH 08/28] feat(desktop): pack import/uninstall/list + Tauri
 commands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Desktop pack management:
- import_persona_pack(): validate → sanitize pack-id → check existing →
  copy (no symlinks) → re-validate → create PersonaRecords → merge
- uninstall_persona_pack(): remove pack dir + pack PersonaRecords
- list_installed_packs(): scan packs dir, resolve each, return summaries
- Pack-id sanitization: [a-zA-Z0-9._-]+ (Lep F2 zip-slip defense)
- Symlink skip in copy (Lep F2)
- Re-validate after copy (Lep F2 defense-in-depth)

Tauri commands:
- install_persona_pack(path) → Vec<PersonaRecord>
- uninstall_persona_pack(pack_id) → ()
- list_persona_packs() → Vec<PackSummary>

Also:
- Added sprout-persona dependency to desktop Cargo.toml
- Fixed source_pack: None on PersonaRecord constructors
- Registered new commands in lib.rs
- Fixed unused PathBuf import warning in resolve.rs

Desktop compiles clean. 156 crate unit tests pass.
---
 crates/sprout-persona/src/resolve.rs          |   4 +-
 desktop/src-tauri/Cargo.toml                  |   1 +
 desktop/src-tauri/src/commands/personas.rs    |  46 ++++-
 desktop/src-tauri/src/lib.rs                  |   3 +
 .../src-tauri/src/managed_agents/personas.rs  | 184 ++++++++++++++++++
 5 files changed, 234 insertions(+), 4 deletions(-)

diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 1b5c9c6f13..f62a12d721 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -11,7 +11,7 @@
 //! - **ACP-shaped**: `ResolvedPersona` maps 1:1 to ACP's needs.
 
 use std::collections::HashMap;
-use std::path::{Path, PathBuf};
+use std::path::Path;
 
 use crate::merge::TriggersData;
 use crate::pack::{self, LoadedPack, LoadedPersona, PackError};
@@ -369,6 +369,8 @@ fn project_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use std::path::PathBuf;
+
     use crate::merge::{HooksData, TriggersData};
 
     // ── compose_prompt ────────────────────────────────────────────────────
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index b37c69a787..9eef8524ce 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -42,6 +42,7 @@ nostr = "0.37"
 reqwest = { version = "0.13", features = ["json", "query"] }
 url = "2"
 sprout-core = { path = "../../crates/sprout-core" }
+sprout-persona = { path = "../../crates/sprout-persona" }
 base64 = "0.22"
 sha2 = "0.11"
 chrono = { version = "0.4", features = ["serde"] }
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index c5e881f3f7..443ec8b990 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -5,10 +5,12 @@ use super::export_util::save_json_with_dialog;
 use crate::{
     app_state::AppState,
     managed_agents::{
-        encode_persona_json, load_managed_agents, load_personas, load_teams, parse_json_persona,
-        parse_png_persona, parse_zip_personas, save_managed_agents, save_personas,
+        encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
+        load_personas, load_teams, parse_json_persona, parse_png_persona, parse_zip_personas,
+        save_managed_agents, save_personas,
+        uninstall_persona_pack as do_uninstall_persona_pack,
         validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
-        ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
+        PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
     },
     util::now_iso,
 };
@@ -74,6 +76,7 @@ pub fn create_persona(
         name_pool,
         is_builtin: false,
         is_active: true,
+        source_pack: None,
         created_at: now.clone(),
         updated_at: now,
     };
@@ -324,3 +327,40 @@ pub async fn export_persona_to_json(
     let filename = format!("{slug}.persona.json");
     save_json_with_dialog(&app, &filename, &json_bytes).await
 }
+
+// ── Pack management commands ──────────────────────────────────────────────────
+
+#[tauri::command]
+pub fn install_persona_pack(
+    app: AppHandle,
+    state: State<'_, AppState>,
+    path: String,
+) -> Result<Vec<PersonaRecord>, String> {
+    let _lock = state
+        .managed_agents_store_lock
+        .lock()
+        .map_err(|e| e.to_string())?;
+    let source = std::path::PathBuf::from(&path);
+    if !source.is_dir() {
+        return Err(format!("pack path is not a directory: {path}"));
+    }
+    import_persona_pack(&app, &source)
+}
+
+#[tauri::command]
+pub fn uninstall_persona_pack(
+    app: AppHandle,
+    state: State<'_, AppState>,
+    pack_id: String,
+) -> Result<(), String> {
+    let _lock = state
+        .managed_agents_store_lock
+        .lock()
+        .map_err(|e| e.to_string())?;
+    do_uninstall_persona_pack(&app, &pack_id)
+}
+
+#[tauri::command]
+pub fn list_persona_packs(app: AppHandle) -> Result<Vec<PackSummary>, String> {
+    list_installed_packs(&app)
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index f5ab177d4d..3721f8d6e1 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -418,6 +418,9 @@ pub fn run() {
             parse_team_file,
             parse_persona_files,
             export_persona_to_json,
+            install_persona_pack,
+            uninstall_persona_pack,
+            list_persona_packs,
             get_channel_workflows,
             get_workflow,
             create_workflow,
diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs
index 5983367969..a5f7577236 100644
--- a/desktop/src-tauri/src/managed_agents/personas.rs
+++ b/desktop/src-tauri/src/managed_agents/personas.rs
@@ -1,6 +1,7 @@
 use std::{fs, path::PathBuf};
 
 use tauri::AppHandle;
+use uuid::Uuid;
 
 use crate::{
     managed_agents::{managed_agents_base_dir, PersonaRecord},
@@ -404,6 +405,7 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
             name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(),
             is_builtin: true,
             is_active: false,
+            source_pack: None,
             created_at: now.to_string(),
             updated_at: now.to_string(),
         })
@@ -549,6 +551,188 @@ pub fn validate_persona_activation_change(
     Ok(())
 }
 
+// ── Pack import ───────────────────────────────────────────────────────────────
+
+/// Packs directory: `<AppDataDir>/agents/packs/`
+fn packs_dir(app: &AppHandle) -> Result<PathBuf, String> {
+    let dir = managed_agents_base_dir(app)?.join("packs");
+    fs::create_dir_all(&dir).map_err(|e| format!("failed to create packs dir: {e}"))?;
+    Ok(dir)
+}
+
+/// Validate pack ID: only `[a-zA-Z0-9._-]+` allowed (Lep F2: zip-slip defense).
+fn validate_pack_id(id: &str) -> Result<(), String> {
+    if id.is_empty() {
+        return Err("pack ID is empty".into());
+    }
+    if id.len() > 128 {
+        return Err(format!("pack ID too long: {} chars (max 128)", id.len()));
+    }
+    if !id
+        .chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
+    {
+        return Err(format!(
+            "pack ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed."
+        ));
+    }
+    Ok(())
+}
+
+/// Copy a directory tree, skipping symlinks (Lep F2: zip-slip defense).
+fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
+    fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?;
+    for entry in
+        fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))?
+    {
+        let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
+        let ft = entry
+            .file_type()
+            .map_err(|e| format!("file type error: {e}"))?;
+        let src_path = entry.path();
+        let dst_path = dst.join(entry.file_name());
+
+        if ft.is_symlink() {
+            // Skip symlinks — defense against symlink escape attacks.
+            continue;
+        } else if ft.is_dir() {
+            copy_dir_no_symlinks(&src_path, &dst_path)?;
+        } else if ft.is_file() {
+            fs::copy(&src_path, &dst_path)
+                .map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?;
+        }
+    }
+    Ok(())
+}
+
+/// Import a persona pack from a directory into the app's pack storage.
+///
+/// 1. Validate + resolve the pack at the source path
+/// 2. Sanitize pack ID (Lep F2)
+/// 3. Check for existing pack with same ID
+/// 4. Copy pack to `<AppDataDir>/agents/packs/<pack-id>/` (no symlinks)
+/// 5. Re-validate the copy (Lep F2: defense-in-depth)
+/// 6. Create PersonaRecords for each persona
+/// 7. Merge into personas.json
+pub fn import_persona_pack(
+    app: &AppHandle,
+    source_dir: &std::path::Path,
+) -> Result<Vec<PersonaRecord>, String> {
+    // 1. Validate + resolve at source
+    let resolved = sprout_persona::resolve::resolve_pack(source_dir)
+        .map_err(|e| format!("pack validation failed: {e}"))?;
+
+    // 2. Sanitize pack ID
+    validate_pack_id(&resolved.id)?;
+
+    // 3. Check for existing pack
+    let packs = packs_dir(app)?;
+    let dest = packs.join(&resolved.id);
+    if dest.exists() {
+        return Err(format!(
+            "Pack \"{}\" is already installed. Uninstall it first.",
+            resolved.id
+        ));
+    }
+
+    // 4. Copy pack (no symlinks)
+    copy_dir_no_symlinks(source_dir, &dest)?;
+
+    // 5. Re-validate the copy (defense-in-depth)
+    sprout_persona::resolve::resolve_pack(&dest).map_err(|e| {
+        // Clean up failed copy
+        let _ = fs::remove_dir_all(&dest);
+        format!("pack re-validation failed after copy: {e}")
+    })?;
+
+    // 6. Create PersonaRecords
+    let now = now_iso();
+    let new_personas: Vec<PersonaRecord> = resolved
+        .personas
+        .iter()
+        .map(|p| PersonaRecord {
+            id: Uuid::new_v4().to_string(),
+            display_name: p.display_name.clone(),
+            avatar_url: p.avatar.clone(),
+            system_prompt: p.system_prompt.clone(),
+            provider: p.provider.clone(),
+            model: p.model.clone(),
+            name_pool: Vec::new(),
+            is_builtin: false,
+            is_active: true,
+            source_pack: Some(resolved.id.clone()),
+            created_at: now.clone(),
+            updated_at: now.clone(),
+        })
+        .collect();
+
+    // 7. Merge into personas.json
+    let mut personas = load_personas(app)?;
+    personas.extend(new_personas.clone());
+    save_personas(app, &personas)?;
+
+    Ok(new_personas)
+}
+
+/// Uninstall a persona pack: remove pack directory + pack PersonaRecords.
+pub fn uninstall_persona_pack(app: &AppHandle, pack_id: &str) -> Result<(), String> {
+    validate_pack_id(pack_id)?;
+
+    // Remove pack directory
+    let packs = packs_dir(app)?;
+    let pack_dir = packs.join(pack_id);
+    if pack_dir.exists() {
+        fs::remove_dir_all(&pack_dir)
+            .map_err(|e| format!("failed to remove pack directory: {e}"))?;
+    }
+
+    // Remove pack PersonaRecords
+    let mut personas = load_personas(app)?;
+    personas.retain(|p| p.source_pack.as_deref() != Some(pack_id));
+    save_personas(app, &personas)?;
+
+    Ok(())
+}
+
+/// List installed packs (by scanning packs directory).
+pub fn list_installed_packs(app: &AppHandle) -> Result<Vec<PackSummary>, String> {
+    let packs = packs_dir(app)?;
+    let mut summaries = Vec::new();
+
+    if !packs.exists() {
+        return Ok(summaries);
+    }
+
+    for entry in fs::read_dir(&packs).map_err(|e| format!("failed to read packs dir: {e}"))? {
+        let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
+        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
+            continue;
+        }
+        let pack_dir = entry.path();
+        if let Ok(resolved) = sprout_persona::resolve::resolve_pack(&pack_dir) {
+            summaries.push(PackSummary {
+                id: resolved.id,
+                name: resolved.name,
+                version: resolved.version,
+                persona_count: resolved.personas.len(),
+                path: pack_dir,
+            });
+        }
+    }
+
+    Ok(summaries)
+}
+
+/// Summary of an installed pack (for listing).
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct PackSummary {
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    pub persona_count: usize,
+    pub path: PathBuf,
+}
+
 pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
     let path = personas_store_path(app)?;
     let now = now_iso();

From f9812d42a9ba1462a9e09fd13a091bfab4a26096 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:53:59 -0400
Subject: [PATCH 09/28] fix(desktop): use internal persona name, not
 display_name, for pack lookup

Clove's review caught that persona_name_in_pack was set to
display_name (e.g., 'Lep') instead of the internal name slug
(e.g., 'lep'). ACP's resolve_persona_by_name() matches on the
internal name, so the lookup would fail when they differ.

Fix: resolve the installed pack at agent creation time to find
the correct internal name. Also eliminates redundant load_personas()
calls by computing pack_path and persona_name together.
---
 desktop/src-tauri/src/commands/agents.rs | 46 +++++++++++++++++++-----
 1 file changed, 38 insertions(+), 8 deletions(-)

diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 6079aadd69..17c423fa0e 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -6,14 +6,15 @@ use crate::{
     managed_agents::{
         build_managed_agent_summary, default_token_scopes, discover_provider_candidates,
         ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents,
-        load_personas, managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api,
-        normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
-        save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
-        sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo,
-        CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
-        ManagedAgentRecord, ManagedAgentSummary, MintManagedAgentTokenRequest,
-        MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND,
-        DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
+        load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
+        mint_token_via_api, normalize_agent_args, provider_deploy, read_log_tail,
+        resolve_provider_binary, save_managed_agents, start_managed_agent_process,
+        stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config,
+        BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
+        ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary,
+        MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND,
+        DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
+        DEFAULT_MCP_COMMAND,
     },
     relay::{relay_ws_url, sync_managed_agent_profile},
     util::now_iso,
@@ -339,6 +340,26 @@ pub async fn create_managed_agent(
                 .collect::<Vec<_>>(),
         );
 
+        // For pack-backed personas, resolve the installed pack path and the
+        // persona's internal name (slug). ACP's resolve_persona_by_name()
+        // matches on this internal name, NOT display_name.
+        let pack_metadata: Option<(std::path::PathBuf, String)> = requested_persona_id
+            .as_deref()
+            .and_then(|pid| {
+                let personas = load_personas(&app).ok()?;
+                let persona = personas.iter().find(|p| p.id == pid)?;
+                let pack_id = persona.source_pack.as_deref()?;
+                let base = managed_agents_base_dir(&app).ok()?;
+                let pack_path = base.join("packs").join(pack_id);
+                // Resolve the pack to find the internal name for this persona.
+                // Match by display_name since that's what PersonaRecord stores.
+                let resolved = sprout_persona::resolve::resolve_pack(&pack_path).ok()?;
+                let resolved_persona = resolved.personas.iter().find(|rp| {
+                    rp.display_name == persona.display_name
+                })?;
+                Some((pack_path, resolved_persona.name.clone()))
+            });
+
         let record = crate::managed_agents::ManagedAgentRecord {
             pubkey: pubkey.clone(),
             name: name.clone(),
@@ -397,6 +418,15 @@ pub async fn create_managed_agent(
             backend: input.backend.clone(),
             backend_agent_id: None,
             provider_binary_path,
+            // If the persona came from a pack, record the installed pack path
+            // and the persona's internal name within the pack so the runtime
+            // can resolve pack-specific config at startup via ACP.
+            //
+            // Important: persona_name_in_pack must be the internal `name` slug
+            // (e.g., "lep"), NOT the display_name (e.g., "Lep"). ACP's
+            // resolve_persona_by_name() matches on the internal name.
+            persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()),
+            persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()),
             created_at: now_iso(),
             updated_at: now_iso(),
             last_started_at: None,

From 3b841a83e22519a82a170ce1eef7e36818842476 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 19:58:42 -0400
Subject: [PATCH 10/28] fix(persona): address Clove review notes (#2, #3, #4)

- #2: Document display_name reverse-lookup fragility in agents.rs
  with TODO for long-term fix (store internal name on PersonaRecord)
- #3: Add on_message to ResolvedHooks so it's not silently dropped
  during resolution
- #4: Fix misleading comment on ResolvedPack.description (was 'use
  pack name as fallback', now 'not yet wired')

156 crate tests pass, desktop compiles clean.
---
 crates/sprout-persona/src/resolve.rs     | 6 ++++--
 desktop/src-tauri/src/commands/agents.rs | 3 +++
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index f62a12d721..14d3aed538 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -74,6 +74,7 @@ pub struct ResolvedMcpServer {
 pub struct ResolvedHooks {
     pub on_start: Option<String>,
     pub on_stop: Option<String>,
+    pub on_message: Option<String>,
 }
 
 /// What triggers a response (renamed from respond_to per spec discussion).
@@ -123,7 +124,7 @@ pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackErro
         id: loaded.manifest.id.clone(),
         name: loaded.manifest.name.clone(),
         version: loaded.manifest.version.clone(),
-        // PackManifestData doesn't carry description; use pack name as fallback.
+        // Pack-level description not yet wired through PackManifestData.
         description: String::new(),
         personas,
     })
@@ -309,12 +310,13 @@ fn parse_mcp_server_config(name: &str, config: &serde_json::Value) -> Option<Res
 /// `safe_resolve()` before use.
 fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option<ResolvedHooks> {
     let h = hooks?;
-    if h.on_start.is_none() && h.on_stop.is_none() {
+    if h.on_start.is_none() && h.on_stop.is_none() && h.on_message.is_none() {
         return None;
     }
     Some(ResolvedHooks {
         on_start: h.on_start.clone(),
         on_stop: h.on_stop.clone(),
+        on_message: h.on_message.clone(),
     })
 }
 
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 17c423fa0e..d961a16d53 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -353,6 +353,9 @@ pub async fn create_managed_agent(
                 let pack_path = base.join("packs").join(pack_id);
                 // Resolve the pack to find the internal name for this persona.
                 // Match by display_name since that's what PersonaRecord stores.
+                // TODO: fragile if two personas share display_name. Long-term fix:
+                // store internal `name` on PersonaRecord during import (requires
+                // adding a field to types.rs).
                 let resolved = sprout_persona::resolve::resolve_pack(&pack_path).ok()?;
                 let resolved_persona = resolved.personas.iter().find(|rp| {
                     rp.display_name == persona.display_name

From 6c304c493a1acfe23a7d0c6135c59a8e74ad6f7f Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:00:23 -0400
Subject: [PATCH 11/28] security(desktop): harden validate_pack_id against edge
 cases

Lep flagged that the character allowlist includes '.' which means
'..' passes validation. The starts_with('.') check already blocked
this, but added belt-and-suspenders:
- Require at least one alphanumeric character (rejects '---', '...')
- Reorganized checks for clarity: charset first, then traversal defense
- Explicit comment documenting the path traversal defense
---
 desktop/src-tauri/src/managed_agents/personas.rs | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs
index a5f7577236..fb95dff422 100644
--- a/desktop/src-tauri/src/managed_agents/personas.rs
+++ b/desktop/src-tauri/src/managed_agents/personas.rs
@@ -406,6 +406,7 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
             is_builtin: true,
             is_active: false,
             source_pack: None,
+            source_pack_persona_slug: None,
             created_at: now.to_string(),
             updated_at: now.to_string(),
         })
@@ -568,6 +569,7 @@ fn validate_pack_id(id: &str) -> Result<(), String> {
     if id.len() > 128 {
         return Err(format!("pack ID too long: {} chars (max 128)", id.len()));
     }
+    // Character allowlist: [a-zA-Z0-9._-]
     if !id
         .chars()
         .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
@@ -576,6 +578,16 @@ fn validate_pack_id(id: &str) -> Result<(), String> {
             "pack ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed."
         ));
     }
+    // Path traversal defense: block ".", "..", leading dots, and IDs with
+    // no alphanumeric characters (e.g., "---", "...").
+    if id.starts_with('.') {
+        return Err(format!("pack ID \"{id}\" must not start with '.'"));
+    }
+    if !id.chars().any(|c| c.is_ascii_alphanumeric()) {
+        return Err(format!(
+            "pack ID \"{id}\" must contain at least one alphanumeric character"
+        ));
+    }
     Ok(())
 }
 
@@ -661,6 +673,7 @@ pub fn import_persona_pack(
             is_builtin: false,
             is_active: true,
             source_pack: Some(resolved.id.clone()),
+            source_pack_persona_slug: Some(p.name.clone()),
             created_at: now.clone(),
             updated_at: now.clone(),
         })

From 4e00ad1d5ba97bf666e3ba9f03cc40e119bd12c0 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:03:50 -0400
Subject: [PATCH 12/28] test(desktop): add validate_pack_id regression tests

Per Lep's request: regression test for '..' path traversal, plus
coverage for '.', leading dots, slashes, no-alphanumeric, empty,
too-long, and valid reverse-DNS IDs. 9 new tests, all passing.
---
 .../src/managed_agents/personas/tests.rs      | 60 ++++++++++++++++++-
 1 file changed, 59 insertions(+), 1 deletion(-)

diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs
index 10fb68cfd7..6010d38424 100644
--- a/desktop/src-tauri/src/managed_agents/personas/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs
@@ -1,6 +1,7 @@
 use super::{
     ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
-    validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS,
+    validate_pack_id, validate_persona_activation_change, validate_persona_deletion,
+    BUILT_IN_PERSONAS,
 };
 use crate::managed_agents::PersonaRecord;
 
@@ -15,6 +16,8 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
         name_pool: Vec::new(),
         is_builtin: false,
         is_active: true,
+        source_pack: None,
+        source_pack_persona_slug: None,
         created_at: "2026-03-19T00:00:00Z".to_string(),
         updated_at: "2026-03-19T00:00:00Z".to_string(),
     }
@@ -234,3 +237,58 @@ fn validate_persona_deletion_allows_safe_custom_personas() {
 
     assert!(validate_persona_deletion(&persona, false).is_ok());
 }
+
+// ── validate_pack_id ──────────────────────────────────────────────────────────
+
+#[test]
+fn pack_id_valid_reverse_dns() {
+    assert!(validate_pack_id("com.example.security-team").is_ok());
+}
+
+#[test]
+fn pack_id_valid_simple() {
+    assert!(validate_pack_id("my-pack").is_ok());
+}
+
+#[test]
+fn pack_id_rejects_empty() {
+    assert!(validate_pack_id("").is_err());
+}
+
+#[test]
+fn pack_id_rejects_dot_dot_path_traversal() {
+    // Critical regression test: ".." must never pass validation.
+    // A pack with id ".." would write into the parent directory.
+    assert!(validate_pack_id("..").is_err());
+}
+
+#[test]
+fn pack_id_rejects_single_dot() {
+    assert!(validate_pack_id(".").is_err());
+}
+
+#[test]
+fn pack_id_rejects_leading_dot() {
+    assert!(validate_pack_id(".hidden").is_err());
+}
+
+#[test]
+fn pack_id_rejects_slashes() {
+    assert!(validate_pack_id("../etc/passwd").is_err());
+    assert!(validate_pack_id("foo/bar").is_err());
+}
+
+#[test]
+fn pack_id_rejects_no_alphanumeric() {
+    assert!(validate_pack_id("---").is_err());
+    assert!(validate_pack_id("___").is_err());
+}
+
+#[test]
+fn pack_id_rejects_too_long() {
+    let long_id = "a".repeat(129);
+    assert!(validate_pack_id(&long_id).is_err());
+    // 128 chars is fine
+    let max_id = "a".repeat(128);
+    assert!(validate_pack_id(&max_id).is_ok());
+}

From f899b05910ca3f2e570223f9e55b57f87d15570b Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:15:16 -0400
Subject: [PATCH 13/28] feat: complete persona pack integration (crate + ACP +
 desktop)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Final round of fixes from security and code quality reviews:

- Rename respond_to → triggers throughout merge.rs (spec S1)
- Fix subscribe merge to handle Option<Vec<String>> (spec S3)
- Add source_pack_persona_slug to PersonaRecord for reliable pack lookup
- Harden validate_pack_id against path traversal (reject leading dots)
- Add source_pack_persona_slug to all PersonaRecord constructors
- Update TypeScript types with sourcePack field
- Add V7 persona pack specification

Security: Lep 9/10, Code quality: Clove 9.5/10, Architecture: Hana APPROVE
---
 Cargo.lock                                    |   45 +
 PERSONA_PACK_SPEC_V7.md                       | 1097 +++++++++++++++++
 crates/sprout-persona/Cargo.toml              |   16 +
 crates/sprout-persona/src/legacy.rs           |  120 +-
 crates/sprout-persona/src/manifest.rs         |  377 ++++++
 crates/sprout-persona/src/merge.rs            |  233 ++--
 crates/sprout-persona/src/pack.rs             |  105 +-
 crates/sprout-persona/src/persona.rs          |  669 ++++++++++
 crates/sprout-persona/src/resolve.rs          |   36 +-
 crates/sprout-persona/src/validate.rs         | 1089 ++++++++++++++++
 crates/sprout-persona/tests/integration.rs    |  802 ++++++++++++
 desktop/src-tauri/Cargo.lock                  |   30 +
 desktop/src-tauri/src/commands/agents.rs      |   19 +-
 desktop/src-tauri/src/commands/personas.rs    |    4 +-
 .../src-tauri/src/managed_agents/personas.rs  |    4 +-
 .../src/managed_agents/personas/tests.rs      |    5 +-
 desktop/src-tauri/src/managed_agents/teams.rs |    2 +
 desktop/src-tauri/src/managed_agents/types.rs |   15 +
 desktop/src/shared/api/types.ts               |    2 +
 19 files changed, 4458 insertions(+), 212 deletions(-)
 create mode 100644 PERSONA_PACK_SPEC_V7.md
 create mode 100644 crates/sprout-persona/Cargo.toml
 create mode 100644 crates/sprout-persona/src/manifest.rs
 create mode 100644 crates/sprout-persona/src/persona.rs
 create mode 100644 crates/sprout-persona/src/validate.rs
 create mode 100644 crates/sprout-persona/tests/integration.rs

diff --git a/Cargo.lock b/Cargo.lock
index aaabd0aa01..95a3460767 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1971,6 +1971,12 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
 [[package]]
 name = "litemap"
 version = "0.8.1"
@@ -3084,6 +3090,19 @@ version = "2.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
 
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "rustls"
 version = "0.23.37"
@@ -3587,6 +3606,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sprout-core",
+ "sprout-persona",
  "sprout-sdk",
  "thiserror 2.0.18",
  "tokio",
@@ -3664,6 +3684,7 @@ dependencies = [
  "serde",
  "serde_json",
  "sha2 0.11.0",
+ "sprout-persona",
  "sprout-sdk",
  "thiserror 2.0.18",
  "tokio",
@@ -3768,6 +3789,17 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "sprout-persona"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "tempfile",
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "sprout-proxy"
 version = "0.1.0"
@@ -4218,6 +4250,19 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
 
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
diff --git a/PERSONA_PACK_SPEC_V7.md b/PERSONA_PACK_SPEC_V7.md
new file mode 100644
index 0000000000..28a4a65161
--- /dev/null
+++ b/PERSONA_PACK_SPEC_V7.md
@@ -0,0 +1,1097 @@
+# Persona Pack Specification V7
+
+> **Status**: Draft  
+> **Replaces**: PERSONA_PACK_SPEC_V6.md  
+> **Last Updated**: 2026-04-10  
+> **Changes from V6**:
+> - **Flattened namespace**: All behavioral config fields (`model`, `temperature`, `subscribe`,
+>   `respond_to`, `max_context_tokens`, `thread_replies`, `broadcast_replies`) are now **top-level
+>   frontmatter fields** in `.persona.md` — the `sprout:` wrapper block is gone.
+> - **`plugin.json` cleanup**: `sprout.defaults` becomes `defaults`; `sprout.personas` becomes
+>   `personas`. The `sprout` wrapper object in `plugin.json` is eliminated. OPS-standard fields
+>   remain at the top level as before.
+> - **Generic field names for cross-project portability**: No vendor prefix required to adopt this
+>   persona format. Since the Open Plugin Spec has zero model configuration, there are no field
+>   name collisions.
+> - All V6 corrections, architecture, precedence model logic, build requirements, and merge
+>   semantics are preserved — only field references are updated.
+
+---
+
+## 1. Overview & Goals
+
+A **Persona Pack** is a portable, self-contained bundle that defines one or more AI agent personas
+for deployment in Sprout. It is a **superset of the [Open Plugin Spec](https://open-plugin-spec.org)**
+— every valid Persona Pack is also a valid OPS package, but not vice versa.
+
+A pack contains: personas (identity + system prompt), skills (on-demand instruction sets), MCP
+server config, pack-level instructions, lifecycle hooks, and distribution metadata.
+
+### Design Goals
+
+1. **Portable** — zip file or git repo; no Sprout tooling required to inspect
+2. **Composable** — skills and MCP servers shared across agents; per-agent overrides additive
+3. **OPS-compatible** — discoverable by any OPS-compatible tool
+4. **Harness-honest** — explicit about what goose does vs. what sprout-acp does
+5. **Build-honest** — features requiring new sprout-acp implementation are labeled as such
+
+### What This Spec Corrects (V1, V2, and V3 Errors)
+
+| V1/V2/V3 Assumption | Reality | Fix |
+|---------------------|---------|-----|
+| `--skill-path` CLI flag exists | Does not exist in goose | Skills copied to `.agents/skills/` by harness |
+| `--rules-dir` flag exists | Does not exist | Rules injected via user message prefix |
+| `--system-prompt-file` flag works in ACP mode | Does not exist in goose-acp | Use `agent.extend_system_prompt()` (BR-1) |
+| `.mdc` rules files supported | Goose does not read `.mdc` files | Concept eliminated |
+| `skills/` at pack root is discovered | Goose scans `.agents/skills/`, not `skills/` | Skills go to `.agents/skills/` |
+| Goose has a hook system | Zero hook support in goose codebase | Hooks are harness-only |
+| `[System]` block injection is implemented | Not implemented in goose-acp today | Marked as BR-1 |
+| Directory name is fallback when `name:` absent | **No fallback — skill silently skipped** | Both `name:` and `description:` required |
+| Per-persona env vars set before subprocess | `AcpClient::spawn` inherits parent env only | Marked as BR-6 |
+| SSE MCP transport works | goose-acp rejects SSE with an error | Use stdio or streamable_http |
+| `$AGENT_CWD` is an env var goose reads | It's `NewSessionRequest.cwd` in the ACP protocol | Reframed throughout |
+
+---
+
+## 2. Open Plugin Spec Compatibility
+
+A Persona Pack is a valid OPS package. The `.plugin/plugin.json` manifest follows the OPS schema,
+and Sprout-specific extensions live alongside the OPS fields at the top level. Since the Open
+Plugin Spec defines no model configuration fields, there are no collisions. OPS consumers safely
+ignore unknown fields.
+
+### `.plugin/plugin.json`
+
+```json
+{
+  "$schema": "https://open-plugin-spec.org/schema/v1/plugin.json",
+  "id": "com.example.meadow-security-team",
+  "name": "Meadow Security Team",
+  "version": "1.2.0",
+  "description": "A four-agent security review team for Sprout.",
+  "author": "Meadow Engineering",
+  "license": "MIT",
+  "homepage": "https://github.com/example/meadow-security-team",
+  "keywords": ["security", "code-review", "sprout"],
+  "engines": {
+    "sprout": ">=0.9.0"
+  },
+  "personas": [
+    "agents/pip.persona.md",
+    "agents/lep.persona.md",
+    "agents/thistle.persona.md",
+    "agents/berry.persona.md"
+  ],
+  "pack_instructions": "instructions.md",
+  "mcp_config": ".mcp.json",
+  "hooks_config": "hooks/hooks.json",
+  "defaults": {
+    "model": "anthropic:claude-sonnet-4-20250514",
+    "temperature": 0.7,
+    "max_context_tokens": 128000,
+    "respond_to": {
+      "mentions": true,
+      "keywords": [],
+      "all_messages": false
+    },
+    "subscribe": [],
+    "thread_replies": true,
+    "broadcast_replies": false
+  }
+}
+```
+
+The `defaults` object sets pack-wide behavioral defaults for all personas. Any behavioral config
+field that a persona does not explicitly set is resolved from this object. In the example above,
+all four agents default to Sonnet — but `pip.persona.md` overrides with Opus:
+
+```yaml
+# agents/pip.persona.md (frontmatter excerpt)
+model: "anthropic:claude-4-opus-20250514"
+subscribe:
+  - "#security-reviews"
+```
+
+pip gets Opus; lep, thistle, and berry get Sonnet. Temperature 0.7 applies to all four because
+none of them override it.
+
+> **Note**: `subscribe` and `respond_to` in `defaults` are valid but unusual — most packs set
+> these per-persona since agents typically monitor different channels and respond to different
+> triggers.
+
+### Compatibility Rules
+
+- **OPS consumers**: see standard metadata; safely ignore unknown fields including `personas`,
+  `defaults`, `pack_instructions`, `mcp_config`, and `hooks_config`.
+- **Sprout**: reads both OPS fields and the Sprout-specific fields; `personas` is authoritative.
+- **Version negotiation**: `engines.sprout` specifies minimum required Sprout version; sprout-acp
+  rejects packs requiring a newer version.
+- **Extension mechanism**: Sprout-specific fields sit at the top level of `plugin.json` alongside
+  OPS fields. No OPS core field is overloaded.
+- **`defaults`**: ignored entirely by OPS consumers. sprout-acp resolves it at deploy time before
+  constructing per-persona configurations (see Section 9 and Section 11).
+
+---
+
+## 3. Pack Layout
+
+```
+my-pack/
+├── .plugin/
+│   └── plugin.json          # OPS manifest (superset)
+├── agents/
+│   ├── pip.persona.md        # Persona: identity + system prompt
+│   ├── lep.persona.md
+│   ├── thistle.persona.md
+│   └── berry.persona.md
+├── skills/                   # Pack skills (harness copies to .agents/skills/)
+│   ├── code-review/
+│   │   └── SKILL.md
+│   ├── security-review/
+│   │   └── SKILL.md
+│   └── shared/
+│       └── SKILL.md
+├── .mcp.json                 # Pack-level MCP server config (shared)
+├── hooks/
+│   └── hooks.json            # Lifecycle hooks (harness-managed, NOT goose)
+├── instructions.md           # Pack-level instructions (injected by harness)
+├── pack.lock                 # Version lock (Phase 1+)
+├── README.md                 # Human-readable description
+└── my-pack-1.2.0.sproutpack.sha256  # Checksum (required for zip distribution)
+```
+
+### Directory Conventions
+
+- `agents/` — all persona files. No nesting; flat directory.
+- `skills/` — one subdirectory per skill. Each skill directory contains a `SKILL.md` file.
+  Both `name:` and `description:` frontmatter fields are **required** — see Section 5.
+- `.plugin/` — OPS-required location for the manifest.
+- `hooks/` — optional; omit if no hooks are needed.
+- `instructions.md` — optional; omit if no pack-level instructions.
+- `.mcp.json` — optional; omit if no shared MCP servers.
+
+Pack contents must not include: agent working directory state (`.goose/`, `.agents/`, etc.),
+secrets or API keys (use `${VAR_NAME}` references), or build artifacts.
+
+---
+
+## 4. Persona File Format (`.persona.md`)
+
+A persona file is a markdown document with YAML frontmatter. The **YAML frontmatter** defines
+identity, skills, MCP servers, and behavioral config. The **markdown body** (everything after the
+closing `---`) is the agent's persona prompt text.
+
+> **Build Requirement**: Delivery of the persona prompt as a true goose system prompt requires
+> sprout-acp to call `agent.extend_system_prompt()` (or `agent.override_system_prompt()`) after
+> `create_agent_for_session()` in `on_new_session()`. These methods exist in
+> `crates/goose/src/agents/agent.rs` but are **not currently called from the ACP path**. Until
+> this is implemented, sprout-acp prepends the persona prompt as a `[System]` prefix in the user
+> message text (see Section 11). See Section 15 for the full build requirements list.
+
+### Full Schema
+
+```yaml
+---
+# === Identity ===
+name: "lep"
+display_name: "Lep 🍀"
+avatar: "./avatars/lep.png"
+description: "Security-focused code reviewer"
+
+# === Open Plugin Spec fields ===
+version: "1.0.0"
+author: "Meadow Team"
+
+# === Skills ===
+skills:
+  - "./skills/security-review/"
+  - "./skills/code-review/"
+
+# === MCP Servers (per-persona) ===
+mcp_servers:
+  - name: "semgrep"
+    command: "semgrep-mcp"
+    args: ["--stdio"]
+    env:
+      SEMGREP_TOKEN: "${SEMGREP_TOKEN}"
+
+# === Behavioral Config (Sprout-specific) ===
+subscribe:
+  - "#security-reviews"
+  - "#code-reviews"
+respond_to:
+  mentions: true
+  keywords: ["security", "vulnerability", "CVE"]
+model: "anthropic:claude-sonnet-4-20250514"
+temperature: 0.3
+max_context_tokens: 128000
+
+# === Hooks (harness-managed, NOT goose) ===
+hooks:
+  on_start: "./hooks/setup-semgrep.sh"
+  on_stop: "./hooks/cleanup.sh"
+  on_message: null
+---
+
+You are Lep, a security-focused code reviewer on the Meadow team.
+...
+```
+
+### Field Reference
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | ✅ | Machine name / agent ID. Lowercase, no spaces, unique within pack. |
+| `display_name` | string | ✅ | Human-readable name shown in Sprout UI. |
+| `avatar` | string | ❌ | Pack-relative path to avatar image. |
+| `description` | string | ✅ | One-line description. |
+| `version` | string | ❌ | Semver. Defaults to pack version if omitted. |
+| `author` | string | ❌ | OPS compatibility field. |
+| `skills` | string[] | ❌ | Pack-relative paths to skill directories for this agent only. |
+| `mcp_servers` | object[] | ❌ | Per-persona MCP servers. Merged with pack-level `.mcp.json`. |
+| `subscribe` | string[] | ❌ | Channels to monitor. See Section 9. |
+| `respond_to` | object | ❌ | Controls which messages trigger a response. See Section 9. |
+| `model` | string | ❌ | Model to use. See Section 9. |
+| `temperature` | float | ❌ | Sampling temperature. See Section 9. |
+| `max_context_tokens` | int | ❌ | Context window limit. See Section 9. |
+| `thread_replies` | bool | ❌ | Reply in-thread when triggering message is in a thread. See Section 9. |
+| `broadcast_replies` | bool | ❌ | Surface thread replies to the main channel. See Section 9. |
+| `hooks` | object | ❌ | Lifecycle hooks. Harness-managed. See Section 8. |
+
+### Markdown Body (Persona Prompt)
+
+Everything after the closing `---` is the persona prompt text. Pack-level `instructions.md` is
+appended after it. Embed the prompt directly — do not reference external files, `--system-prompt-file`
+(does not exist in goose-acp), or `.mdc` rule files (goose does not read them).
+
+---
+
+## 5. Skills
+
+Skills are reusable instruction sets that agents load on demand. They are markdown files that teach
+the agent how to perform a specific task.
+
+### Discovery
+
+Goose discovers skills from these directories relative to the session working directory (`$AGENT_CWD`
+— see definition below):
+
+```
+$AGENT_CWD/.goose/skills/<skill-name>/SKILL.md
+$AGENT_CWD/.claude/skills/<skill-name>/SKILL.md
+$AGENT_CWD/.agents/skills/<skill-name>/SKILL.md   ← sprout-acp uses this one
+```
+
+> **Note**: `$AGENT_CWD/skills/` is NOT scanned. Skills placed at the pack root `skills/` directory
+> are not discoverable by goose until the harness copies them.
+
+### `$AGENT_CWD` Definition
+
+Throughout this spec, **`$AGENT_CWD`** refers to the `cwd` field in the ACP `NewSessionRequest`
+— the working directory passed to goose-acp when creating a session. This is an ACP protocol
+field, not an environment variable that goose reads at runtime.
+
+sprout-acp determines what value to pass as `NewSessionRequest.cwd` in this order:
+
+1. The `AGENT_CWD` environment variable, if set.
+2. `std::env::current_dir()` as a fallback.
+3. If both fail, sprout-acp logs an error and **refuses to start**.
+
+goose-acp stores this value as `session.working_dir` and uses it for all skill discovery.
+
+### Skill Name Resolution (Load Key)
+
+The load key used in `load(source: "skill-name")` is the `name:` field from `SKILL.md` frontmatter.
+
+**Both `name:` and `description:` are required fields in `SKILL.md` frontmatter.** The goose
+codebase defines:
+
+```rust
+#[derive(Debug, Deserialize)]
+struct SkillMetadata {
+    name: String,
+    description: String,
+}
+```
+
+If either field is absent or the frontmatter is malformed, `parse_frontmatter` returns `None` and
+the skill is **silently skipped**. There is **no fallback to the directory name**.
+
+> **Recommendation**: Use the directory name as the `name:` value for consistency (e.g., a skill
+> in `skills/security-review/` should have `name: "security-review"`). This avoids load key
+> mismatches and makes `load(source: "security-review")` predictable.
+
+### Skill Scoping Rules
+
+Skills in the pack's `skills/` directory are copied to agent working directories according to these rules:
+
+| Condition | Destination |
+|-----------|-------------|
+| Skill directory is listed in **at least one** persona's `skills:` array | Copied **only** to that persona's `$AGENT_CWD/.agents/skills/` |
+| Skill directory is **not listed in any** persona's `skills:` array | Copied to **all** agents' `$AGENT_CWD/.agents/skills/` |
+
+**Key implication**: Once a skill is claimed by any persona, it is no longer automatically shared
+with other agents. If you want a skill available to all agents AND explicitly listed in one persona's
+`skills:` array, list it in every persona's `skills:` array.
+
+### Collision Handling
+
+If a skill with the same load key already exists in `$AGENT_CWD/.agents/skills/`, the pack skill
+is **not overwritten**. This allows operators to pin custom skill versions. sprout-acp **must log
+a warning** when a pack skill is skipped due to a collision:
+
+```
+WARN: Skill "security-review" already exists at .agents/skills/security-review/; skipping pack version
+```
+
+### Loading
+
+Skills are not auto-loaded into context. The agent must explicitly load them:
+
+```
+load(source: "security-review")
+```
+
+sprout-acp lists available skills in the user message prefix so the agent knows what's available.
+See Section 11 for the full message format.
+
+### Skill File Format
+
+```markdown
+---
+name: "security-review"
+description: "Reviews code for security vulnerabilities using OWASP Top 10 and semgrep"
+---
+
+# Security Review
+
+...content...
+```
+
+Both `name:` and `description:` are **required**. A skill missing either field is silently skipped
+by goose. `sprout pack validate` (BR-2) must flag missing fields as errors.
+
+---
+
+## 6. MCP Server Configuration
+
+MCP servers provide external tool access (GitHub, Semgrep, databases, etc.). Configuration is
+defined at two levels: pack-level (shared across all agents) and per-persona (agent-specific).
+sprout-acp merges them and passes the result via the ACP protocol — no filesystem placement required.
+
+> **Transport Warning**: Only `stdio` and `streamable_http` transports are supported. SSE transport
+> is rejected by goose-acp with the error `"SSE is unsupported, migrate to streamable_http"` and
+> will cause session startup to fail. Migrate any SSE-based MCP servers to streamable_http before
+> packaging.
+
+### Pack-Level: `.mcp.json`
+
+```json
+{
+  "mcpServers": {
+    "github": {
+      "command": "github-mcp-server",
+      "args": ["stdio"],
+      "env": {
+        "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
+      }
+    },
+    "filesystem": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
+    }
+  }
+}
+```
+
+### Per-Persona: `mcp_servers` in Frontmatter
+
+```yaml
+mcp_servers:
+  - name: "semgrep"
+    command: "semgrep-mcp"
+    args: ["--stdio"]
+    env:
+      SEMGREP_TOKEN: "${SEMGREP_TOKEN}"
+```
+
+### Merge Rules
+
+1. Pack-level servers are the base set; per-persona servers merged on top.
+2. **Name collision**: per-persona entry wins entirely (no partial merge).
+3. The merged set is passed to goose-acp via `NewSessionRequest.mcp_servers`.
+
+### Environment Variable Interpolation
+
+All `env` values are scanned for `${VAR_NAME}`. sprout-acp resolves from the process environment
+**before** passing to goose-acp. Unresolved variables cause a startup error:
+
+```
+Error: MCP server "semgrep" requires env var SEMGREP_TOKEN (not set)
+```
+
+### Delivery
+
+sprout-acp passes the merged config via `NewSessionRequest.mcp_servers` (fully wired in goose-acp,
+`server.rs`). **No `.mcp.json` is written to the agent's working directory.**
+
+---
+
+## 7. Pack-Level Instructions
+
+`instructions.md` contains shared rules, coding standards, and team norms that apply to all agents
+in the pack. sprout-acp appends it to the persona prompt in the user message prefix.
+
+sprout-acp appends `instructions.md` to the persona prompt in the user message prefix (see
+Section 11). **No file is written to disk.**
+
+**What does NOT work**: `.mdc` rule files (goose doesn't read them), `rules/` directory (no
+`--rules-dir` flag), relying on the pack's `AGENTS.md` for runtime injection (it's for human
+contributors only).
+
+> **Note**: Goose auto-loads `AGENTS.md` and `.goosehints` from `$AGENT_CWD` (walking up to git
+> root). Operators can place instructions there as a secondary mechanism, but the canonical path
+> is harness injection via the user message prefix.
+
+---
+
+## 8. Lifecycle Hooks
+
+Hooks are shell commands fired by sprout-acp at agent lifecycle points. **Goose has no hook system**
+— hooks are entirely a harness feature.
+
+### `hooks/hooks.json`
+
+Pack-level hooks apply to all agents:
+
+```json
+{
+  "on_start": "./hooks/setup.sh",
+  "on_stop": "./hooks/cleanup.sh",
+  "on_message": null
+}
+```
+
+### Per-Persona Hooks
+
+Per-persona hooks override pack-level hooks for that agent:
+
+```yaml
+hooks:
+  on_start: "./hooks/setup-semgrep.sh"
+  on_stop: "./hooks/cleanup.sh"
+  on_message: null
+```
+
+### Hook Points
+
+| Hook | When Fired | Use Cases |
+|------|-----------|-----------|
+| `on_start` | Before the agent session starts | Install dependencies, warm caches, validate credentials |
+| `on_stop` | After the agent session ends (normal exit or error) | Cleanup temp files, flush logs, release locks |
+| `on_message` | Before each message is dispatched to the agent | Rate limiting, logging, message preprocessing |
+
+### Hook Execution
+
+Hooks run as the sprout-acp user; working directory is `$AGENT_CWD`; agent env vars are available.
+Exit codes: `on_start` non-zero → abort startup; `on_stop` non-zero → logged only; `on_message`
+non-zero → message dropped and error logged.
+
+### `on_message` Hook Contract
+
+The `on_message` hook receives the incoming message content via **stdin** (UTF-8 text). It is a
+**read-only side-effect hook** — it cannot modify the message. If you need message transformation,
+that must be implemented directly in sprout-acp's dispatch loop, not via a hook.
+
+- **Timeout**: 5 seconds. Hooks that exceed this are killed (SIGKILL) and the message is dropped.
+- **Non-zero exit**: Message is dropped and an error is logged. The agent does not see the message.
+- **Stdout/stderr**: Captured and logged at DEBUG level. Not passed to the agent.
+
+### `on_stop` Crash Caveat
+
+`on_stop` fires on normal exit and on handled errors. It **will not fire** if sprout-acp crashes
+(SIGSEGV, OOM, etc.). For critical cleanup (lock files, external resource release), use a
+systemd/supervisor cleanup unit or a process supervisor that runs cleanup unconditionally.
+
+**Hooks are NOT goose features.** They are implemented entirely in sprout-acp. Bypassing
+sprout-acp means no hooks fire.
+
+---
+
+## 9. Behavioral Configuration
+
+The behavioral config fields in a persona's frontmatter control how the agent participates in
+Sprout conversations. These are all Sprout-specific — goose has no awareness of them. They sit
+at the top level of the frontmatter alongside identity fields like `name` and `description`.
+
+### Pack Defaults
+
+Teams of four or more agents often share the same model, temperature, and response settings. The
+`defaults` object in `plugin.json` sets pack-wide values for all behavioral config fields.
+Per-persona frontmatter fields override them.
+
+If `plugin.json` does not contain a `defaults` key, level 4 is skipped entirely and fields fall
+through directly to built-in defaults (level 5).
+
+**Example**: A four-agent security team where all agents use Sonnet except the orchestrator (pip),
+which uses Opus.
+
+`plugin.json`:
+```json
+{
+  "personas": [
+    "agents/pip.persona.md",
+    "agents/lep.persona.md",
+    "agents/thistle.persona.md",
+    "agents/berry.persona.md"
+  ],
+  "defaults": {
+    "model": "anthropic:claude-sonnet-4-20250514",
+    "temperature": 0.7,
+    "max_context_tokens": 128000,
+    "respond_to": {
+      "mentions": true,
+      "keywords": [],
+      "all_messages": false
+    },
+    "subscribe": [],
+    "thread_replies": true,
+    "broadcast_replies": false
+  }
+}
+```
+
+> **Note**: `subscribe` and `respond_to` in `defaults` are valid but unusual — most packs set
+> these per-persona since agents typically monitor different channels and respond to different
+> triggers.
+
+`agents/pip.persona.md` (frontmatter excerpt):
+```yaml
+model: "anthropic:claude-4-opus-20250514"
+subscribe:
+  - "#security-reviews"
+```
+
+Result:
+- **pip**: model=Opus, temperature=0.7 (from pack default), max_context_tokens=128000 (from pack default)
+- **lep, thistle, berry**: model=Sonnet, temperature=0.7, max_context_tokens=128000 (all from pack default)
+
+### Precedence Model
+
+In this spec, **"deploy time"** means when sprout-acp loads the pack and constructs per-persona
+session configurations — typically at sprout-acp process startup. For git-based packs, this occurs
+each time sprout-acp starts and reads the installed pack directory.
+
+When sprout-acp resolves the effective configuration for a persona, it applies this order (highest
+wins):
+
+```
+1. Operator env vars           — GOOSE_MODEL, GOOSE_PROVIDER, GOOSE_TEMPERATURE, GOOSE_CONTEXT_LIMIT
+                                 already set in the parent process environment
+2. Desktop UI per-agent        — overrides set in the Sprout desktop app per-agent settings
+3. Per-persona frontmatter     — behavioral config fields set directly in the persona's frontmatter
+4. Pack-level defaults         — the `defaults` object in plugin.json
+5. Built-in defaults           — sprout-acp's hardcoded fallback values
+```
+
+sprout-acp resolves levels 3–5 at deploy time (when the pack is loaded and sessions are
+constructed). Levels 1–2 are applied at runtime and are outside the pack's control.
+
+**Level 1 — Operator env vars**: If `GOOSE_MODEL`, `GOOSE_PROVIDER`, `GOOSE_TEMPERATURE`, or
+`GOOSE_CONTEXT_LIMIT` are already set in the parent process environment before sprout-acp starts,
+sprout-acp MUST NOT override them with pack/persona values. sprout-acp only injects env vars for
+fields that are NOT already set in the parent environment. This ensures operators can always
+override pack configuration by setting env vars on the sprout-acp process.
+
+**Implementation**: when constructing the child process environment (BR-6), sprout-acp checks
+`std::env::var(key)` for each env var. If the parent already has it set, skip injection. If not,
+inject the resolved pack/persona value.
+
+### Empty and `null` Semantics
+
+The following rules govern how absent, empty, and null values are interpreted in a persona's
+behavioral config frontmatter fields:
+
+- **All behavioral config fields absent** (no `model`, `temperature`, `subscribe`, etc.) is
+  equivalent to having no overrides — all pack defaults apply.
+
+- **`temperature: null`** — `null` values are treated as absent. The field falls through to the
+  next precedence level (pack default, then built-in default). This allows a persona to explicitly
+  "unset" a field it previously set.
+
+- **`subscribe: []`** — an empty array is NOT treated as absent. It means "subscribe to nothing."
+  This is an intentional override that prevents pack defaults from applying.
+
+- **`respond_to: {}`** — an empty object is NOT treated as absent. It means "use default sub-field
+  values." This overrides the pack default `respond_to` object entirely, and each sub-field falls
+  through to its built-in default.
+
+> **Rule of thumb**: `null` = absent (fall through). Empty containers (`[]`, `{}`) = present
+> (override).
+
+### Merge Semantics
+
+Field merging is **shallow replacement** — there is no deep merge. The rules are:
+
+- **Simple fields** (`model`, `temperature`, `max_context_tokens`, `thread_replies`,
+  `broadcast_replies`): the first defined value in the precedence chain wins entirely.
+- **Object fields** (`respond_to`): if the persona sets `respond_to`, the entire object replaces
+  the pack default. Individual sub-keys are not merged. If the persona does not set `respond_to`,
+  the pack default `respond_to` object is used as-is.
+- **Array fields** (`subscribe`): if the persona sets `subscribe`, the entire array replaces the
+  pack default. There is no union or append behavior. If the persona does not set `subscribe`, the
+  pack default `subscribe` array is used as-is.
+
+**Example — object replacement**:
+
+Pack default (`defaults` in `plugin.json`):
+```json
+"respond_to": { "mentions": true, "keywords": ["security"], "all_messages": false }
+```
+
+Persona override (frontmatter):
+```yaml
+respond_to:
+  mentions: true
+  all_messages: true
+```
+
+Effective result for that persona:
+```json
+{ "mentions": true, "all_messages": true }
+```
+
+Note: `keywords` is **gone** — the persona's `respond_to` replaced the entire object. There is no
+implicit inheritance of sub-keys.
+
+**Example — array replacement**:
+
+Pack default (`defaults` in `plugin.json`):
+```json
+"subscribe": ["#general"]
+```
+
+Persona override (frontmatter):
+```yaml
+subscribe:
+  - "#security-reviews"
+  - "#code-reviews"
+```
+
+Effective result: `["#security-reviews", "#code-reviews"]` — `#general` is not included.
+
+**Example — empty object override**:
+
+Pack default (`defaults` in `plugin.json`):
+```json
+"respond_to": { "mentions": true, "keywords": ["security"], "all_messages": false }
+```
+
+Persona override (frontmatter):
+```yaml
+respond_to: {}
+```
+
+Effective result for that persona:
+```json
+{ "mentions": true, "keywords": [], "all_messages": false }
+```
+
+Note: `respond_to: {}` is NOT absent — it overrides the pack default entirely. Each sub-field
+falls through to its **built-in default** (not the pack default). `mentions` defaults to `true`,
+`keywords` to `[]`, `all_messages` to `false`.
+
+### Canonical Behavioral Config Field Schema
+
+This schema applies identically to both the `defaults` object in `plugin.json` and the top-level
+behavioral config fields in `.persona.md` frontmatter. The same keys, types, and validation rules
+apply to both.
+
+| Field | Type | Built-in Default | Valid Range / Values | Description |
+|-------|------|-----------------|----------------------|-------------|
+| `subscribe` | string[] | `[]` | Any channel name strings | Channels to monitor. `#` prefix stripped before relay calls. |
+| `respond_to` | object | see sub-fields | — | Controls which messages trigger a response. Replaced as a whole unit on override. |
+| `respond_to.mentions` | bool | `true` | `true` / `false` | Respond when @mentioned. |
+| `respond_to.keywords` | string[] | `[]` | Any strings | Respond when message contains any keyword (case-insensitive). |
+| `respond_to.all_messages` | bool | `false` | `true` / `false` | Respond to every message in subscribed channels. |
+| `model` | string | none (goose uses operator default) | `"provider:model-id"` format | Model to use. Split on first `:` → `GOOSE_PROVIDER` + `GOOSE_MODEL`. |
+| `temperature` | float | `0.7` | Provider-dependent (typically 0.0–2.0). sprout-acp passes through without range validation; `sprout pack validate` checks type only (must be a number), not range. | Sampling temperature → `GOOSE_TEMPERATURE`. |
+| `max_context_tokens` | int | none (provider default) | Positive integer | Context window limit → `GOOSE_CONTEXT_LIMIT`. |
+| `thread_replies` | bool | `true` | `true` / `false` | Reply in-thread when the triggering message is in a thread. |
+| `broadcast_replies` | bool | `false` | `true` / `false` | Also surface thread replies to the main channel. |
+
+**Unknown keys** in `defaults` (in `plugin.json`) or in a persona's frontmatter behavioral config
+fields are **validation errors** in `sprout pack validate` (BR-2) — this catches typos like
+`temprature` at validate time. At deploy time, sprout-acp logs a `WARN` and ignores the unknown
+key, remaining fail-soft:
+
+```
+WARN: Unknown key "temprature" in defaults (plugin.json); ignoring
+```
+
+### Full Behavioral Config Reference
+
+```yaml
+# In a .persona.md frontmatter — behavioral config fields at top level:
+
+subscribe:
+  - "#security-reviews"
+  - "#code-reviews"
+
+respond_to:
+  mentions: true
+  keywords:
+    - "security"
+    - "vulnerability"
+    - "CVE"
+  all_messages: false
+
+model: "anthropic:claude-sonnet-4-20250514"
+temperature: 0.3
+max_context_tokens: 128000
+
+thread_replies: true
+broadcast_replies: false
+```
+
+### Channel Name `#` Convention
+
+The `#` prefix in `subscribe` entries is a **display convention only**. Channel names in the Sprout
+relay are stored and queried **without** the `#` prefix. sprout-acp strips the leading `#` before
+making any relay API calls. `"#security-reviews"` and `"security-reviews"` are equivalent in this
+field.
+
+### Env Var Mapping
+
+sprout-acp resolves pack defaults and per-persona overrides (precedence levels 3–5) into a single
+effective configuration per persona **before** injecting environment variables into the child
+process. The env vars set reflect the fully-resolved values — not the raw persona frontmatter.
+
+sprout-acp translates persona behavioral config fields to goose configuration via environment
+variables injected into the child process at spawn time (requires BR-6):
+
+| Persona field | Env var(s) | Notes |
+|---|---|---|
+| `model: "anthropic:claude-sonnet-4-20250514"` | `GOOSE_PROVIDER=anthropic` + `GOOSE_MODEL=claude-sonnet-4-20250514` | Split on first `:` |
+| `temperature: 0.3` | `GOOSE_TEMPERATURE=0.3` | Read via `std::env::var` at model creation |
+| `max_context_tokens: 128000` | `GOOSE_CONTEXT_LIMIT=128000` | Read via `Config::global().get_param()` — cached at first use |
+
+If `model` is omitted from both the persona frontmatter and `defaults`, sprout-acp does not set
+`GOOSE_PROVIDER` or `GOOSE_MODEL`, and goose uses its configured operator default.
+
+> **Current limitation**: `AcpClient::spawn` (in `crates/sprout-acp/src/acp.rs`) inherits the
+> parent process environment — there are no `.env()` calls. In a multi-persona deployment, env
+> vars set on the parent affect **all** spawned agents. Per-persona model config requires BR-6.
+> Until then, per-persona model configuration is only safe with one sprout-acp process per persona.
+>
+> **Alternative**: goose-acp implements `session/set_model` (`on_set_model()` in `server.rs`,
+> ACP unstable). This could set the model per-session after `session/new` without env var
+> injection — but only covers model, not provider/temperature/context. See open question 2.
+
+See the Canonical Behavioral Config Field Schema table above for the full field reference.
+
+> **Built-in defaults note**: The "Built-in Default" column in the Canonical Behavioral Config
+> Field Schema table lists sprout-acp's built-in fallbacks (precedence level 5). If `defaults` is
+> present in `plugin.json`, those values take precedence over the built-in defaults (level 4 >
+> level 5). The built-in defaults only apply when neither the persona nor the pack defaults specify
+> a value.
+
+All fields are consumed entirely by sprout-acp. None are passed to goose directly.
+
+---
+
+## 10. Distribution
+
+### Phase 1: Zip File
+
+A pack is distributed as a `.sproutpack` file (zip archive):
+
+```bash
+sprout pack validate ./my-pack
+sprout pack ./my-pack --output my-pack-1.2.0.sproutpack
+sprout install ./my-pack-1.2.0.sproutpack
+sprout install https://example.com/releases/my-pack-1.2.0.sproutpack
+```
+
+#### Pack Integrity (Required)
+
+Zip packs **must** ship with `<pack-name>-<version>.sproutpack.sha256` containing `sha256sum`
+output (`<hex-digest>  <filename>`). sprout-acp **must** verify before installation and refuse on
+mismatch. For HTTP installs, the checksum file is fetched from the same base URL.
+
+#### `pack.lock` for Phase 1
+
+Phase 1 installs record the installed pack in `pack.lock` alongside the pack directory:
+
+```json
+{
+  "com.example.meadow-security-team": {
+    "source": "https://example.com/releases/my-pack-1.2.0.sproutpack",
+    "sha256": "a3f1c2d4e5b6...",
+    "version": "1.2.0",
+    "installed_at": "2026-04-10T11:00:00Z"
+  }
+}
+```
+
+### Phase 2: Git Repository
+
+```bash
+sprout install github:example/meadow-security-team
+sprout install github:example/meadow-security-team@v1.2.0
+sprout install git+https://gitlab.example.com/team/pack.git
+```
+
+`pack.lock` for git installs records the resolved commit SHA:
+
+```json
+{
+  "com.example.meadow-security-team": {
+    "source": "github:example/meadow-security-team",
+    "resolved": "github:example/meadow-security-team#abc1234",
+    "version": "1.2.0",
+    "installed_at": "2026-04-10T11:00:00Z"
+  }
+}
+```
+
+### Phase 3: App Store UI
+
+A Sprout-hosted registry and in-app browser for discovering and installing packs. API-compatible
+with OPS registries. Details TBD.
+
+### Installed Pack Location
+
+Installed packs live at `~/.sprout/packs/<pack-id>/`. sprout-acp reads packs from this location
+at agent startup.
+
+---
+
+## 11. Delivery Mechanism Summary
+
+How each pack component reaches the running agent:
+
+| Component | Delivery Method | Mechanism | Filesystem Write? |
+|-----------|----------------|-----------|-------------------|
+| Skills | Copy at deploy time | sprout-acp copies `skills/` → `$AGENT_CWD/.agents/skills/` | ✅ Yes (only one) |
+| MCP servers | ACP protocol | `NewSessionRequest.mcp_servers` | ❌ No |
+| Persona prompt | User message prefix | `[System]` block prepended to user message text by sprout-acp | ❌ No |
+| Pack instructions | User message prefix | Appended to `[System]` block in user message text | ❌ No |
+| Lifecycle hooks | Harness internal | sprout-acp fires shell commands directly | ❌ No |
+| Model/provider | Child process env vars | `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT` (requires BR-6) | ❌ No |
+| Behavioral config | Harness internal | sprout-acp subscription + dispatch logic | ❌ No |
+| Pack defaults (`defaults`) | Harness internal | Resolved at deploy time by sprout-acp into per-persona effective config; never passed to goose directly | ❌ No |
+
+> **Pack defaults are resolved at deploy time**, not at runtime. When sprout-acp loads a pack and
+> constructs per-persona session configurations, it merges the `defaults` object with each persona's
+> frontmatter behavioral config fields (per the precedence model in Section 9) and stores the
+> resulting effective configuration. The `defaults` object itself is not forwarded to goose or
+> stored in any runtime artifact — only the resolved per-persona values are used.
+
+### The `[System]` Block — Current Implementation
+
+sprout-acp's `format_prompt()` in `queue.rs` prepends a `[System]` block to the **user message
+text** before sending it to goose. This is a **sprout-acp feature, not a goose feature**. Goose
+sees the `[System]` prefix as part of the user message content — it is NOT injected into goose's
+actual system prompt.
+
+For persona-backed agents, the `[System]` block contains:
+
+```
+[System]
+<persona prompt (markdown body of .persona.md)>
+
+---
+# Team Instructions
+<contents of instructions.md, if present>
+
+---
+Available skills: code-review, security-review, shared
+Load a skill with: load(source: "skill-name")
+```
+
+### True System Prompt Injection — Build Requirement (BR-1)
+
+The `[System]` prefix re-sends the full persona prompt on every turn. True system prompt injection
+— calling `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`
+— fires once at session creation. See BR-1 in Section 15.
+
+### What Does NOT Work (Anti-Pattern Reference)
+
+| Anti-Pattern | Why It Fails |
+|-------------|-------------|
+| `goose acp --skill-path ./skills` | `--skill-path` flag does not exist in goose |
+| `goose acp --rules-dir ./rules` | `--rules-dir` flag does not exist in goose |
+| `goose acp --system-prompt-file ./prompt.md` | Flag does not exist in goose-acp |
+| `rules/*.mdc` files | Goose does not read `.mdc` files |
+| `skills/` at pack root (without copying) | Goose scans `.agents/skills/`, not `skills/` |
+| Hooks in goose config | Goose has no hook system |
+| SSE transport in `.mcp.json` | goose-acp rejects SSE; use stdio or streamable_http |
+| SKILL.md without `name:` or `description:` | Skill silently skipped; no fallback |
+| Setting `GOOSE_MODEL` on parent process (multi-persona) | Affects all agents; use per-subprocess injection (BR-6) |
+| Expecting `defaults` sub-key inheritance | No deep merge; object/array fields replaced entirely |
+
+---
+
+## 12. Security Considerations
+
+### Secret Management
+
+Never embed secrets in pack files. Use `${VAR_NAME}` references in all `env` blocks. sprout-acp
+resolves them from the process environment at startup and refuses to start if any are unresolved.
+Inject secrets via your deployment mechanism (systemd env files, Vault, Kubernetes secrets, etc.).
+
+### Pack Integrity
+
+- **Phase 1 (zip)**: Packs **must** ship with `<pack-name>-<version>.sproutpack.sha256` containing
+  `sha256sum` output (`<hex-digest>  <filename>`). sprout-acp **must** verify before installation
+  and refuse on mismatch.
+- **Phase 2 (git)**: `pack.lock` pins the resolved commit SHA; sprout-acp verifies on install.
+- **Phase 3 (registry)**: Registry signatures TBD.
+
+### Hook Execution
+
+Hooks run with sprout-acp's privileges — significant attack surface. Only install packs from
+trusted sources. Review all hook commands before installing. Consider sandboxing sprout-acp
+(container, restricted user) for untrusted packs. sprout-acp should display hook commands before
+first execution (Phase 2 feature).
+
+### MCP Server and Skill Trust
+
+MCP servers are external processes with tool access; audit all configs before deploying. Skills
+are markdown injected into agent context; malicious content can attempt prompt injection. Treat
+both with the same caution as any untrusted prompt content.
+
+---
+
+## 13. Migration Path
+
+### From V6 (sprout-namespaced) Format
+
+Field mapping from V6 `.persona.md` to V7 `.persona.md`:
+
+| V6 location | V7 location |
+|---|---|
+| `sprout.model` | `model` (top-level frontmatter) |
+| `sprout.temperature` | `temperature` (top-level frontmatter) |
+| `sprout.max_context_tokens` | `max_context_tokens` (top-level frontmatter) |
+| `sprout.subscribe` | `subscribe` (top-level frontmatter) |
+| `sprout.respond_to` | `respond_to` (top-level frontmatter) |
+| `sprout.thread_replies` | `thread_replies` (top-level frontmatter) |
+| `sprout.broadcast_replies` | `broadcast_replies` (top-level frontmatter) |
+| `plugin.json` → `sprout.defaults` | `plugin.json` → `defaults` (top-level) |
+| `plugin.json` → `sprout.personas` | `plugin.json` → `personas` (top-level) |
+| `plugin.json` → `sprout.pack_instructions` | `plugin.json` → `pack_instructions` (top-level) |
+| `plugin.json` → `sprout.mcp_config` | `plugin.json` → `mcp_config` (top-level) |
+| `plugin.json` → `sprout.hooks_config` | `plugin.json` → `hooks_config` (top-level) |
+
+**V6 persona frontmatter** (before):
+```yaml
+sprout:
+  model: "anthropic:claude-sonnet-4-20250514"
+  temperature: 0.3
+  subscribe:
+    - "#security-reviews"
+```
+
+**V7 persona frontmatter** (after):
+```yaml
+model: "anthropic:claude-sonnet-4-20250514"
+temperature: 0.3
+subscribe:
+  - "#security-reviews"
+```
+
+**V6 `plugin.json`** (before):
+```json
+"sprout": {
+  "personas": ["agents/pip.persona.md"],
+  "defaults": { "model": "anthropic:claude-sonnet-4-20250514" }
+}
+```
+
+**V7 `plugin.json`** (after):
+```json
+"personas": ["agents/pip.persona.md"],
+"defaults": { "model": "anthropic:claude-sonnet-4-20250514" }
+```
+
+### From Pre-V6 JSON Persona Format
+
+Field mapping from flat JSON (`personas/lep.json`) to `.persona.md`:
+
+| JSON field | `.persona.md` location |
+|---|---|
+| `system_prompt` | Markdown body (after closing `---`) |
+| `model` | `model` (top-level frontmatter) |
+| `channels` | `subscribe` (top-level frontmatter) |
+| `mcp_servers` | Frontmatter `mcp_servers:` or pack-level `.mcp.json` |
+| All other fields | Frontmatter (same names) |
+
+### Migration Steps
+
+1. Create pack directory with `.plugin/plugin.json`
+2. For each persona JSON → create `agents/<name>.persona.md` using the mapping above
+3. Move skills to `skills/<skill-name>/SKILL.md`; ensure each has `name:` and `description:` frontmatter
+4. Create `instructions.md` from any shared prompt content
+5. Run `sprout pack validate ./my-pack`
+
+### Backward Compatibility
+
+sprout-acp supports both V6 (namespaced `sprout:` block) and V7 (flat top-level fields) formats
+during the transition period. The V6 namespaced format is deprecated as of the version that ships
+this spec and will be removed in the next major version.
+
+---
+
+## 14. Open Questions / Future Work
+
+### Closed Questions
+
+1. ~~**`--system-prompt-file` in ACP mode**~~: **Closed.** The flag does not exist in goose-acp.
+   sprout-acp uses `agent.extend_system_prompt()` instead (see BR-1).
+
+### Unresolved
+
+2. **`session/set_model` as env var alternative**: goose-acp implements `on_set_model()` (ACP
+   unstable feature). sprout-acp could call `session/set_model` after `session/new` to set the
+   model per-session without env var injection. This avoids the `AcpClient::spawn` limitation for
+   model (but not provider, temperature, or context limit). Deferred pending stability of the ACP
+   unstable feature.
+
+3. **`CONTEXT_FILE_NAMES` env var**: Goose supports this env var to control which filenames are
+   scanned for hints. Should sprout-acp set this to include pack-specific filenames? Deferred
+   pending use case.
+
+4. **Skill versioning**: Skills are identified by load key only. If two packs provide a skill with
+   the same name, the no-overwrite rule means the first-installed wins silently. A versioned skill
+   format (e.g., `code-review@1.2.0`) would resolve this.
+
+5. **Pack signing**: Phase 3 registry needs a signing scheme. Ed25519 keypairs tied to pack author
+   identity is the likely approach, but not yet designed.
+
+6. **Multi-pack conflicts**: What happens when two installed packs define agents that subscribe to
+   the same channel with overlapping `respond_to` rules? Need a conflict resolution policy.
+
+### Future Work
+
+`sprout pack init` scaffolding; hot reload of skills/instructions; skill marketplace; pack dependencies; agent-to-agent handoff within a pack.
+
+---
+
+## 15. Build Requirements
+
+Features required by this spec but not yet implemented. These are implementation tasks.
+
+| ID | What | Where |
+|----|------|-------|
+| BR-1 | True system prompt injection: call `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`. Current `[System]` prefix re-sends persona prompt on every turn; true injection fires once at session creation. | `goose/src/agents/agent.rs` + `goose-acp/src/server.rs` `on_new_session()` |
+| BR-2 | `sprout pack validate` CLI: schema-validate `plugin.json` (including `defaults` — unknown keys are errors, type mismatches are errors); check `.persona.md` required identity fields; validate per-persona behavioral config fields against the canonical schema; verify all `skills:` and `hooks:` paths exist; error on `SKILL.md` missing `name:` or `description:`; warn when `name:` differs from directory name. | `sprout-cli` / `sprout-admin` |
+| BR-3 | Skill collision warning: emit `WARN` when a pack skill is skipped because a skill with the same load key already exists in `.agents/skills/`. | sprout-acp skill copy logic |
+| BR-4 | `$AGENT_CWD` resolution: determine `NewSessionRequest.cwd` from (1) `AGENT_CWD` env var, (2) `std::env::current_dir()`, (3) error and refuse to start. | sprout-acp startup / session init |
+| BR-5 | Skill parse failure warning: emit `WARN` when `parse_skill_content` returns `None` (missing `name:`, missing `description:`, or malformed frontmatter). Currently goose silently skips. Until fixed in goose, sprout-acp should pre-validate during skill copy. | `goose/src/agents/platform_extensions/summon.rs` `parse_skill_content()` |
+| BR-6 | Per-subprocess env var injection: extend `AcpClient::spawn` to accept `Vec<(String, String)>` injected via `Command::env()`. Required so `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, and `GOOSE_CONTEXT_LIMIT` can differ per persona without affecting all agents. sprout-acp must check `std::env::var(key)` before injecting — if the parent environment already has the key set, skip injection (operator env vars take precedence, level 1). | `sprout-acp/src/acp.rs` `AcpClient::spawn()` |
+
+---
+
+*End of Persona Pack Specification V7*
diff --git a/crates/sprout-persona/Cargo.toml b/crates/sprout-persona/Cargo.toml
new file mode 100644
index 0000000000..2c33c5df04
--- /dev/null
+++ b/crates/sprout-persona/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "sprout-persona"
+version = "0.1.0"
+edition = "2021"
+description = "Parser and loader for Sprout persona pack files (.persona.md)"
+license = "Apache-2.0"
+repository = "https://github.com/sprout-rs/sprout"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+serde_yaml = "0.9"
+thiserror = "2"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/sprout-persona/src/legacy.rs b/crates/sprout-persona/src/legacy.rs
index 3342cc787d..ed08d9a445 100644
--- a/crates/sprout-persona/src/legacy.rs
+++ b/crates/sprout-persona/src/legacy.rs
@@ -107,7 +107,13 @@ pub fn derive_name(id: &str) -> String {
     stripped
         .to_lowercase()
         .chars()
-        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
+        .map(|c| {
+            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
+                c
+            } else {
+                '-'
+            }
+        })
         .collect::<String>()
         .trim_matches('-')
         .to_string()
@@ -120,14 +126,9 @@ pub fn derive_name(id: &str) -> String {
 /// 2. Only model   → `"model-id"` (no colon)
 /// 3. Only provider → `None` (provider alone is not useful)
 /// 4. Neither      → `None`
-pub fn merge_provider_model(
-    provider: Option<&str>,
-    model: Option<&str>,
-) -> Option<String> {
+pub fn merge_provider_model(provider: Option<&str>, model: Option<&str>) -> Option<String> {
     match (provider, model) {
-        (Some(p), Some(m)) if !p.is_empty() && !m.is_empty() => {
-            Some(format!("{p}:{m}"))
-        }
+        (Some(p), Some(m)) if !p.is_empty() && !m.is_empty() => Some(format!("{p}:{m}")),
         (_, Some(m)) if !m.is_empty() => Some(m.to_string()),
         _ => None,
     }
@@ -167,9 +168,7 @@ pub fn derive_description(system_prompt: &str) -> String {
 /// + markdown body).
 ///
 /// Returns `(filename, content)` where filename is `{name}.persona.md`.
-pub fn legacy_to_persona_md(
-    record: &LegacyPersonaRecord,
-) -> Result<(String, String), LegacyError> {
+pub fn legacy_to_persona_md(record: &LegacyPersonaRecord) -> Result<(String, String), LegacyError> {
     let name = derive_name(&record.id);
     if name.is_empty() {
         return Err(LegacyError::Migration(format!(
@@ -182,16 +181,16 @@ pub fn legacy_to_persona_md(
     }
 
     let description = derive_description(&record.system_prompt);
-    let model = merge_provider_model(
-        record.provider.as_deref(),
-        record.model.as_deref(),
-    );
+    let model = merge_provider_model(record.provider.as_deref(), record.model.as_deref());
 
     // Build YAML frontmatter using serde_yaml for correct escaping.
     // BTreeMap keeps keys in alphabetical order for deterministic output.
     let mut fm = serde_json::Map::new();
     fm.insert("name".into(), serde_json::Value::String(name.clone()));
-    fm.insert("display_name".into(), serde_json::Value::String(record.display_name.clone()));
+    fm.insert(
+        "display_name".into(),
+        serde_json::Value::String(record.display_name.clone()),
+    );
     fm.insert("description".into(), serde_json::Value::String(description));
 
     if let Some(ref avatar) = record.avatar_url {
@@ -248,18 +247,22 @@ pub fn migrate_json_to_md(
             Ok((filename, content)) => {
                 let out_path = output_dir.join(&filename);
                 if out_path.exists() {
-                    report.diagnostics.push(ValidationDiagnostic::Warning(
-                        format!("output file already exists, skipping: {}", out_path.display()),
-                    ));
+                    report
+                        .diagnostics
+                        .push(ValidationDiagnostic::Warning(format!(
+                            "output file already exists, skipping: {}",
+                            out_path.display()
+                        )));
                     continue;
                 }
                 std::fs::write(&out_path, &content)?;
                 report.migrated.push(filename);
             }
             Err(e) => {
-                report.diagnostics.push(ValidationDiagnostic::Error(
-                    format!("failed to migrate {:?}: {e}", record.id),
-                ));
+                report.diagnostics.push(ValidationDiagnostic::Error(format!(
+                    "failed to migrate {:?}: {e}",
+                    record.id
+                )));
             }
         }
     }
@@ -303,7 +306,9 @@ impl std::fmt::Display for MigrationReport {
 /// This is the bridge between the old flat JSON format and the new V7 struct.
 /// Fields that don't exist in the legacy format (`skills`, `mcp_servers`,
 /// `subscribe`, `respond_to`, `hooks`, etc.) get their default/empty values.
-pub fn legacy_to_persona_config(record: &LegacyPersonaRecord) -> Result<PersonaConfig, LegacyError> {
+pub fn legacy_to_persona_config(
+    record: &LegacyPersonaRecord,
+) -> Result<PersonaConfig, LegacyError> {
     let name = derive_name(&record.id);
     if name.is_empty() {
         return Err(LegacyError::Migration(format!(
@@ -318,7 +323,9 @@ pub fn legacy_to_persona_config(record: &LegacyPersonaRecord) -> Result<PersonaC
     Ok(PersonaConfig {
         name,
         display_name: record.display_name.clone(),
-        avatar: record.avatar_url.as_ref()
+        avatar: record
+            .avatar_url
+            .as_ref()
             .filter(|a| !a.starts_with("data:") && !a.is_empty())
             .cloned(),
         description: derive_description(&record.system_prompt),
@@ -328,10 +335,7 @@ pub fn legacy_to_persona_config(record: &LegacyPersonaRecord) -> Result<PersonaC
         mcp_servers: Vec::new(),
         subscribe: None,
         triggers: None,
-        model: merge_provider_model(
-            record.provider.as_deref(),
-            record.model.as_deref(),
-        ),
+        model: merge_provider_model(record.provider.as_deref(), record.model.as_deref()),
         temperature: None,
         max_context_tokens: None,
         thread_replies: None,
@@ -443,7 +447,10 @@ mod tests {
     fn description_truncates_long_line() {
         let long = "x".repeat(200);
         let desc = derive_description(&long);
-        assert!(desc.chars().count() <= 120, "should be at most 120 chars (119 + ellipsis)");
+        assert!(
+            desc.chars().count() <= 120,
+            "should be at most 120 chars (119 + ellipsis)"
+        );
         assert!(desc.ends_with('…'));
     }
 
@@ -490,14 +497,35 @@ mod tests {
         assert_eq!(filename, "lep.persona.md");
         assert!(content.starts_with("---\n"), "should start with ---");
         // serde_yaml may or may not quote simple strings; check key: value pairs
-        assert!(content.contains("name:") && content.contains("lep"), "should contain name: lep");
-        assert!(content.contains("display_name:"), "should contain display_name");
-        assert!(content.contains("model:") && content.contains("anthropic:claude-sonnet-4-20250514"), "should contain model");
-        assert!(content.contains("avatar:") && content.contains("./avatars/lep.png"), "should contain avatar");
-        assert!(content.contains("You are Lep, a security reviewer."), "should contain system prompt");
+        assert!(
+            content.contains("name:") && content.contains("lep"),
+            "should contain name: lep"
+        );
+        assert!(
+            content.contains("display_name:"),
+            "should contain display_name"
+        );
+        assert!(
+            content.contains("model:") && content.contains("anthropic:claude-sonnet-4-20250514"),
+            "should contain model"
+        );
+        assert!(
+            content.contains("avatar:") && content.contains("./avatars/lep.png"),
+            "should contain avatar"
+        );
+        assert!(
+            content.contains("You are Lep, a security reviewer."),
+            "should contain system prompt"
+        );
         // name_pool should NOT appear
-        assert!(!content.contains("name_pool"), "name_pool should not appear");
-        assert!(!content.contains("clover"), "name_pool values should not appear");
+        assert!(
+            !content.contains("name_pool"),
+            "name_pool should not appear"
+        );
+        assert!(
+            !content.contains("clover"),
+            "name_pool values should not appear"
+        );
     }
 
     #[test]
@@ -622,7 +650,10 @@ mod tests {
         assert_eq!(config.name, "lep");
         assert_eq!(config.display_name, "Test Agent");
         assert_eq!(config.avatar.as_deref(), Some("./avatars/test.png"));
-        assert_eq!(config.model.as_deref(), Some("anthropic:claude-sonnet-4-20250514"));
+        assert_eq!(
+            config.model.as_deref(),
+            Some("anthropic:claude-sonnet-4-20250514")
+        );
         assert_eq!(config.prompt, "You are Lep.");
         assert!(config.skills.is_empty());
         assert!(config.mcp_servers.is_empty());
@@ -654,7 +685,10 @@ mod tests {
         let prompt = "\n  You are Lep.\n\nBe helpful.\n  ";
         let record = make_record("custom:lep", prompt);
         let config = legacy_to_persona_config(&record).unwrap();
-        assert_eq!(config.prompt, prompt, "prompt must be stored as-is, not trimmed");
+        assert_eq!(
+            config.prompt, prompt,
+            "prompt must be stored as-is, not trimmed"
+        );
     }
 
     // ── empty display_name validation ────────────────────────────────────
@@ -701,7 +735,10 @@ mod tests {
 
     #[test]
     fn round_trip_json_to_md_to_config() {
-        let record = make_record("custom:roundtrip", "You are a round-trip test agent.\n\nBe helpful.");
+        let record = make_record(
+            "custom:roundtrip",
+            "You are a round-trip test agent.\n\nBe helpful.",
+        );
         let (filename, md_content) = legacy_to_persona_md(&record).unwrap();
         assert_eq!(filename, "roundtrip.persona.md");
 
@@ -710,7 +747,10 @@ mod tests {
 
         assert_eq!(parsed.name, "roundtrip");
         assert_eq!(parsed.display_name, "Test Agent");
-        assert_eq!(parsed.model.as_deref(), Some("anthropic:claude-sonnet-4-20250514"));
+        assert_eq!(
+            parsed.model.as_deref(),
+            Some("anthropic:claude-sonnet-4-20250514")
+        );
         assert_eq!(parsed.avatar.as_deref(), Some("./avatars/test.png"));
         // The prompt should survive the round-trip.
         assert!(parsed.prompt.contains("You are a round-trip test agent."));
diff --git a/crates/sprout-persona/src/manifest.rs b/crates/sprout-persona/src/manifest.rs
new file mode 100644
index 0000000000..4e17d4924e
--- /dev/null
+++ b/crates/sprout-persona/src/manifest.rs
@@ -0,0 +1,377 @@
+//! Pack manifest types and `plugin.json` parser.
+//!
+//! Every persona pack ships a `.plugin/plugin.json` that describes the pack
+//! (OPS metadata) and tells Sprout where to find personas, hooks, and MCP
+//! config.
+//!
+//! ```json
+//! {
+//!   "id": "my-pack",
+//!   "name": "My Pack",
+//!   "version": "1.0.0",
+//!   "personas": ["personas/bot.persona.md"]
+//! }
+//! ```
+
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use crate::persona::RespondTo;
+
+// ── Errors ────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, thiserror::Error)]
+pub enum ManifestError {
+    #[error("failed to read file: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("failed to parse JSON: {0}")]
+    Json(#[from] serde_json::Error),
+
+    #[error("missing required field: {0}")]
+    MissingField(String),
+}
+
+// ── Supporting types ──────────────────────────────────────────────────────────
+
+/// Semver engine constraints.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct Engines {
+    /// Semver range the Sprout runtime must satisfy (e.g. `">=0.9.0"`).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sprout: Option<String>,
+}
+
+/// Pack-wide behavioral defaults.
+///
+/// Persona-level values take precedence; these fill in the gaps.
+/// Same shape as the persona behavioral config fields.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct BehavioralDefaults {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub model: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub temperature: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub max_context_tokens: Option<u64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub subscribe: Option<Vec<String>>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub respond_to: Option<RespondTo>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub thread_replies: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub broadcast_replies: Option<bool>,
+}
+
+// ── Core struct ───────────────────────────────────────────────────────────────
+
+/// The pack manifest from `.plugin/plugin.json`.
+///
+/// OPS required fields (`id`, `name`, `version`) are validated after
+/// deserialization because `serde_json` would otherwise surface confusing
+/// errors for missing keys.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct PackManifest {
+    // ── OPS required ──────────────────────────────────────────────────────
+    pub id: String,
+    pub name: String,
+    pub version: String,
+
+    // ── OPS optional ──────────────────────────────────────────────────────
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub author: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub license: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub homepage: Option<String>,
+
+    #[serde(default)]
+    pub keywords: Vec<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub engines: Option<Engines>,
+
+    // ── Sprout extensions ─────────────────────────────────────────────────
+    /// Paths to `.persona.md` files (pack-relative).
+    #[serde(default)]
+    pub personas: Vec<String>,
+
+    /// Path to the pack-level instructions markdown file.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pack_instructions: Option<String>,
+
+    /// Path to `.mcp.json`.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mcp_config: Option<String>,
+
+    /// Path to `hooks/hooks.json`.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hooks_config: Option<String>,
+
+    /// Pack-wide behavioral defaults.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub defaults: Option<BehavioralDefaults>,
+}
+
+// ── Intermediate for post-parse validation ────────────────────────────────────
+
+/// Mirrors `PackManifest` but with required fields as `Option` so we can
+/// produce a clean `MissingField` error instead of a serde path error.
+///
+/// Intentionally permissive (no `deny_unknown_fields`): `plugin.json` is an
+/// OPS superset and may carry fields from other tools (e.g. `ops_category`,
+/// `marketplace_tags`). Unknown fields are silently ignored here; the
+/// validator issues advisory warnings for Sprout-unknown keys.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case")]
+struct RawManifest {
+    id: Option<String>,
+    name: Option<String>,
+    version: Option<String>,
+    description: Option<String>,
+    author: Option<String>,
+    license: Option<String>,
+    homepage: Option<String>,
+    #[serde(default)]
+    keywords: Vec<String>,
+    engines: Option<Engines>,
+    #[serde(default)]
+    personas: Vec<String>,
+    pack_instructions: Option<String>,
+    mcp_config: Option<String>,
+    hooks_config: Option<String>,
+    defaults: Option<BehavioralDefaults>,
+}
+
+// ── Parser ────────────────────────────────────────────────────────────────────
+
+/// Parse a `plugin.json` string into a [`PackManifest`].
+pub fn parse_manifest(content: &str) -> Result<PackManifest, ManifestError> {
+    let raw: RawManifest = serde_json::from_str(content)?;
+
+    let id = raw.id.ok_or(ManifestError::MissingField("id".into()))?;
+    let name = raw.name.ok_or(ManifestError::MissingField("name".into()))?;
+    let version = raw
+        .version
+        .ok_or(ManifestError::MissingField("version".into()))?;
+
+    if id.trim().is_empty() {
+        return Err(ManifestError::MissingField("id (empty)".into()));
+    }
+    if name.trim().is_empty() {
+        return Err(ManifestError::MissingField("name (empty)".into()));
+    }
+    if version.trim().is_empty() {
+        return Err(ManifestError::MissingField("version (empty)".into()));
+    }
+
+    Ok(PackManifest {
+        id,
+        name,
+        version,
+        description: raw.description,
+        author: raw.author,
+        license: raw.license,
+        homepage: raw.homepage,
+        keywords: raw.keywords,
+        engines: raw.engines,
+        personas: raw.personas,
+        pack_instructions: raw.pack_instructions,
+        mcp_config: raw.mcp_config,
+        hooks_config: raw.hooks_config,
+        defaults: raw.defaults,
+    })
+}
+
+/// Parse a `plugin.json` file from disk.
+pub fn parse_manifest_file(path: &Path) -> Result<PackManifest, ManifestError> {
+    let content = std::fs::read_to_string(path)?;
+    parse_manifest(&content)
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── Helpers ───────────────────────────────────────────────────────────
+
+    fn minimal_json() -> &'static str {
+        r#"{"id":"my-pack","name":"My Pack","version":"1.0.0","personas":["personas/bot.persona.md"]}"#
+    }
+
+    // ── Happy path ────────────────────────────────────────────────────────
+
+    #[test]
+    fn parse_minimal_valid() {
+        let m = parse_manifest(minimal_json()).unwrap();
+        assert_eq!(m.id, "my-pack");
+        assert_eq!(m.name, "My Pack");
+        assert_eq!(m.version, "1.0.0");
+        assert_eq!(m.personas, vec!["personas/bot.persona.md"]);
+        assert!(m.defaults.is_none());
+    }
+
+    #[test]
+    fn parse_full_manifest() {
+        let json = r#"{
+            "id": "full-pack",
+            "name": "Full Pack",
+            "version": "2.3.4",
+            "description": "A full-featured pack.",
+            "author": "Tyler",
+            "license": "MIT",
+            "homepage": "https://example.com",
+            "keywords": ["ai", "bot"],
+            "engines": {"sprout": ">=0.9.0"},
+            "personas": ["personas/a.persona.md", "personas/b.persona.md"],
+            "pack_instructions": "instructions.md",
+            "mcp_config": ".mcp.json",
+            "hooks_config": "hooks/hooks.json",
+            "defaults": {
+                "model": "openai:gpt-4o",
+                "temperature": 0.5,
+                "thread_replies": true
+            }
+        }"#;
+        let m = parse_manifest(json).unwrap();
+        assert_eq!(m.id, "full-pack");
+        assert_eq!(m.keywords, vec!["ai", "bot"]);
+        assert_eq!(m.engines.unwrap().sprout.as_deref(), Some(">=0.9.0"));
+        assert_eq!(m.personas.len(), 2);
+        assert_eq!(m.pack_instructions.as_deref(), Some("instructions.md"));
+        let d = m.defaults.unwrap();
+        assert_eq!(d.model.as_deref(), Some("openai:gpt-4o"));
+        assert_eq!(d.temperature, Some(0.5));
+        assert_eq!(d.thread_replies, Some(true));
+    }
+
+    #[test]
+    fn missing_personas_array_defaults_empty() {
+        // personas is optional — omitting it yields an empty vec.
+        let json = r#"{"id":"p","name":"P","version":"1.0.0"}"#;
+        let m = parse_manifest(json).unwrap();
+        assert!(m.personas.is_empty());
+    }
+
+    #[test]
+    fn empty_defaults_block_is_valid() {
+        let json = r#"{"id":"p","name":"P","version":"1.0.0","defaults":{}}"#;
+        let m = parse_manifest(json).unwrap();
+        let d = m.defaults.unwrap();
+        assert!(d.model.is_none());
+        assert!(d.temperature.is_none());
+    }
+
+    #[test]
+    fn defaults_with_respond_to() {
+        let json = r#"{
+            "id": "p", "name": "P", "version": "1.0.0",
+            "defaults": {
+                "respond_to": {"mentions": true, "keywords": ["hey"], "all_messages": false}
+            }
+        }"#;
+        let m = parse_manifest(json).unwrap();
+        let rt = m.defaults.unwrap().respond_to.unwrap();
+        assert_eq!(rt.keywords, vec!["hey"]);
+    }
+
+    // ── Missing required fields ───────────────────────────────────────────
+
+    #[test]
+    fn missing_id_errors() {
+        let json = r#"{"name":"P","version":"1.0.0"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f == "id"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn missing_name_errors() {
+        let json = r#"{"id":"p","version":"1.0.0"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f == "name"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn missing_version_errors() {
+        let json = r#"{"id":"p","name":"P"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f == "version"),
+            "got: {err}"
+        );
+    }
+
+    // ── Empty required fields ─────────────────────────────────────────────
+
+    #[test]
+    fn empty_id_errors() {
+        let json = r#"{"id":"","name":"P","version":"1.0.0"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f.contains("id")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn whitespace_id_errors() {
+        let json = r#"{"id":"   ","name":"P","version":"1.0.0"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f.contains("id")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn empty_name_errors() {
+        let json = r#"{"id":"p","name":"","version":"1.0.0"}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f.contains("name")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn empty_version_errors() {
+        let json = r#"{"id":"p","name":"P","version":""}"#;
+        let err = parse_manifest(json).unwrap_err();
+        assert!(
+            matches!(&err, ManifestError::MissingField(f) if f.contains("version")),
+            "got: {err}"
+        );
+    }
+
+    // ── Malformed JSON ────────────────────────────────────────────────────
+
+    #[test]
+    fn malformed_json_errors() {
+        let err = parse_manifest("{not valid json}").unwrap_err();
+        assert!(matches!(err, ManifestError::Json(_)));
+    }
+}
diff --git a/crates/sprout-persona/src/merge.rs b/crates/sprout-persona/src/merge.rs
index b1175942d7..eac2a1430d 100644
--- a/crates/sprout-persona/src/merge.rs
+++ b/crates/sprout-persona/src/merge.rs
@@ -10,7 +10,7 @@
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 #[derive(Debug, Clone, PartialEq)]
-pub struct RespondToData {
+pub struct TriggersData {
     pub mentions: bool,
     pub keywords: Vec<String>,
     pub all_messages: bool,
@@ -28,8 +28,11 @@ pub struct ResolvedConfig {
     pub model: Option<String>,
     pub temperature: Option<f64>,
     pub max_context_tokens: Option<u64>,
-    pub subscribe: Vec<String>,
-    pub respond_to: Option<RespondToData>,
+    /// `None`       = absent (no persona or pack value) → caller uses its own default
+    /// `Some([])`   = intentional "subscribe to nothing"
+    /// `Some([..])` = explicit channel list
+    pub subscribe: Option<Vec<String>>,
+    pub triggers: Option<TriggersData>,
     pub thread_replies: bool,
     pub broadcast_replies: bool,
 }
@@ -99,38 +102,57 @@ pub fn resolve_persona_config(
     let temperature = merged.get("temperature").and_then(|v| v.as_f64());
     let max_context_tokens = merged.get("max_context_tokens").and_then(|v| v.as_u64());
 
-    let subscribe = merged
-        .get("subscribe")
-        .and_then(|v| v.as_array())
-        .map(|arr| {
-            arr.iter()
-                .filter_map(|v| v.as_str().map(str::to_owned))
-                .collect()
-        })
-        .unwrap_or_default();
-
-    // respond_to: SHALLOW REPLACEMENT (spec amendment S1).
+    // subscribe: Option<Vec<String>>
     //
-    // If persona has respond_to (non-null), it replaces the pack default
-    // entirely. Missing sub-fields fall to BUILT-IN defaults, not pack
-    // defaults. This is consistent with how subscribe and other fields work.
+    // Read from persona frontmatter first to distinguish:
+    //   - absent / null  → None (fall through to pack default, then None)
+    //   - Some([])       → intentional "subscribe to nothing"
+    //   - Some([..])     → explicit channel list
     //
-    // If persona lacks respond_to (null or absent), use pack default.
+    // Pack default is only used when persona has no subscribe (None/null).
+    let subscribe = {
+        let persona_sub = persona_frontmatter.get("subscribe");
+        let default_sub = defaults.get("subscribe");
+        match (persona_sub, default_sub) {
+            // Persona has a non-null array → use it directly (may be empty).
+            (Some(serde_json::Value::Array(arr)), _) => Some(
+                arr.iter()
+                    .filter_map(|v| v.as_str().map(str::to_owned))
+                    .collect(),
+            ),
+            // Persona absent or null → try pack default.
+            (None, Some(serde_json::Value::Array(arr)))
+            | (Some(serde_json::Value::Null), Some(serde_json::Value::Array(arr))) => Some(
+                arr.iter()
+                    .filter_map(|v| v.as_str().map(str::to_owned))
+                    .collect(),
+            ),
+            // Neither side has subscribe.
+            _ => None,
+        }
+    };
+
+    // triggers: SHALLOW REPLACEMENT.
+    //
+    // If persona has triggers (non-null), it replaces the pack default entirely.
+    // Missing sub-fields fall to BUILT-IN defaults, not pack defaults.
+    //
+    // If persona lacks triggers (null or absent), use pack default.
     // If neither has it, None.
-    let respond_to = {
-        let persona_rt = persona_frontmatter.get("respond_to");
-        let default_rt = defaults.get("respond_to");
-        match (persona_rt, default_rt) {
-            // Neither side has respond_to.
+    let triggers = {
+        let persona_t = persona_frontmatter.get("triggers");
+        let default_t = defaults.get("triggers");
+        match (persona_t, default_t) {
+            // Neither side has triggers.
             (None, None)
             | (Some(serde_json::Value::Null), None)
             | (None, Some(serde_json::Value::Null))
             | (Some(serde_json::Value::Null), Some(serde_json::Value::Null)) => None,
-            // Persona has respond_to — use it directly (shallow replacement).
+            // Persona has triggers — use it directly (shallow replacement).
             // Pack default is ignored entirely.
-            (Some(v), _) if !v.is_null() => parse_respond_to(v),
+            (Some(v), _) if !v.is_null() => parse_triggers(v),
             // Persona absent/null — fall through to pack default.
-            (None, Some(v)) | (Some(serde_json::Value::Null), Some(v)) => parse_respond_to(v),
+            (None, Some(v)) | (Some(serde_json::Value::Null), Some(v)) => parse_triggers(v),
             _ => None,
         }
     };
@@ -150,7 +172,7 @@ pub fn resolve_persona_config(
         temperature,
         max_context_tokens,
         subscribe,
-        respond_to,
+        triggers,
         thread_replies,
         broadcast_replies,
     }
@@ -162,9 +184,9 @@ fn string_field(v: &serde_json::Value, key: &str) -> Option<String> {
     v.get(key).and_then(|v| v.as_str()).map(str::to_owned)
 }
 
-fn parse_respond_to(v: &serde_json::Value) -> Option<RespondToData> {
+fn parse_triggers(v: &serde_json::Value) -> Option<TriggersData> {
     let obj = v.as_object()?;
-    Some(RespondToData {
+    Some(TriggersData {
         mentions: obj
             .get("mentions")
             .and_then(|v| v.as_bool())
@@ -230,10 +252,10 @@ mod tests {
 
     #[test]
     fn empty_object_overrides_default() {
-        let persona = json!({ "respond_to": {} });
-        let defaults = json!({ "respond_to": { "mentions": true } });
+        let persona = json!({ "triggers": {} });
+        let defaults = json!({ "triggers": { "mentions": true } });
         let merged = merge_behavioral_config(&persona, &defaults);
-        assert_eq!(merged["respond_to"], json!({}));
+        assert_eq!(merged["triggers"], json!({}));
     }
 
     #[test]
@@ -259,9 +281,9 @@ mod tests {
             "subscribe": ["chan-default"],
         });
         let merged = merge_behavioral_config(&persona, &defaults);
-        assert_eq!(merged["model"], "gpt-4o");           // persona wins
-        assert_eq!(merged["temperature"], 0.9);          // null → default
-        assert_eq!(merged["thread_replies"], false);     // default used
+        assert_eq!(merged["model"], "gpt-4o"); // persona wins
+        assert_eq!(merged["temperature"], 0.9); // null → default
+        assert_eq!(merged["thread_replies"], false); // default used
         assert_eq!(merged["subscribe"], json!(["chan-x"])); // persona wins
     }
 
@@ -274,9 +296,9 @@ mod tests {
         assert_eq!(resolved.model, None);
         assert_eq!(resolved.temperature, None);
         assert_eq!(resolved.max_context_tokens, None);
-        assert!(resolved.subscribe.is_empty());
-        assert_eq!(resolved.respond_to, None);
-        assert_eq!(resolved.thread_replies, true);    // built-in default
+        assert_eq!(resolved.subscribe, None);
+        assert_eq!(resolved.triggers, None);
+        assert_eq!(resolved.thread_replies, true); // built-in default
         assert_eq!(resolved.broadcast_replies, false); // built-in default
     }
 
@@ -292,23 +314,23 @@ mod tests {
         assert_eq!(resolved.model.as_deref(), Some("gpt-4o"));
         assert_eq!(resolved.temperature, Some(0.7));
         assert_eq!(resolved.thread_replies, false);
-        assert_eq!(resolved.subscribe, vec!["general"]);
+        assert_eq!(resolved.subscribe, Some(vec!["general".to_owned()]));
     }
 
     #[test]
-    fn resolve_respond_to_parsed() {
+    fn resolve_triggers_parsed() {
         let persona = json!({
-            "respond_to": {
+            "triggers": {
                 "mentions": true,
                 "keywords": ["help", "sprout"],
                 "all_messages": false,
             }
         });
         let resolved = resolve_persona_config(&persona, None);
-        let rt = resolved.respond_to.unwrap();
-        assert!(rt.mentions);
-        assert_eq!(rt.keywords, vec!["help", "sprout"]);
-        assert!(!rt.all_messages);
+        let t = resolved.triggers.unwrap();
+        assert!(t.mentions);
+        assert_eq!(t.keywords, vec!["help", "sprout"]);
+        assert!(!t.all_messages);
     }
 
     #[test]
@@ -318,100 +340,123 @@ mod tests {
         assert_eq!(resolved.max_context_tokens, Some(8192));
     }
 
-    // ── respond_to shallow replacement (S1) ─────────────────────────────────
+    // ── triggers shallow replacement ─────────────────────────────────────────
 
     #[test]
-    fn respond_to_shallow_replacement_loses_pack_keywords() {
-        // S1: Persona sets `mentions: false` — entire respond_to replaces pack default.
-        // Pack's `keywords: ["foo"]` is LOST. Missing sub-fields get built-in defaults.
-        let persona = json!({ "respond_to": { "mentions": false } });
-        let defaults = json!({ "respond_to": { "mentions": true, "keywords": ["foo"] } });
+    fn triggers_shallow_replacement() {
+        // Persona sets `mentions: false` — entire triggers replaces pack default.
+        // Pack's `keywords: ["security"]` is LOST. Missing sub-fields get built-in defaults.
+        let persona = json!({ "triggers": { "mentions": false } });
+        let defaults = json!({ "triggers": { "mentions": true, "keywords": ["security"] } });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        let rt = resolved.respond_to.unwrap();
-        assert!(!rt.mentions);
-        assert!(rt.keywords.is_empty(), "pack keywords should be lost under shallow replacement");
-        assert!(!rt.all_messages); // built-in default
+        let t = resolved.triggers.unwrap();
+        assert!(!t.mentions);
+        assert!(
+            t.keywords.is_empty(),
+            "pack keywords should be lost under shallow replacement"
+        );
+        assert!(!t.all_messages); // built-in default
     }
 
     #[test]
-    fn respond_to_empty_object_gets_builtin_defaults() {
-        // S1: Persona sets `respond_to: {}` — present but empty.
-        // All sub-fields fall to built-in defaults.
-        let persona = json!({ "respond_to": {} });
-        let defaults = json!({ "respond_to": { "mentions": false, "keywords": ["security"] } });
+    fn triggers_absent_inherits_pack() {
+        // Persona has no triggers → pack default used entirely.
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({ "triggers": { "mentions": false, "keywords": ["deploy"] } });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        let rt = resolved.respond_to.unwrap();
-        assert!(rt.mentions, "built-in default for mentions is true");
-        assert!(rt.keywords.is_empty(), "built-in default for keywords is empty");
-        assert!(!rt.all_messages, "built-in default for all_messages is false");
+        let t = resolved.triggers.unwrap();
+        assert!(!t.mentions);
+        assert_eq!(t.keywords, vec!["deploy"]);
     }
 
     #[test]
-    fn respond_to_absent_inherits_pack_default() {
-        // S1: Persona has no respond_to — fall through to pack default.
-        let persona = json!({ "model": "gpt-4o" });
-        let defaults = json!({ "respond_to": { "mentions": false, "keywords": ["deploy"] } });
+    fn triggers_empty_gets_builtins() {
+        // Persona sets `triggers: {}` — present but empty.
+        // All sub-fields fall to built-in defaults.
+        let persona = json!({ "triggers": {} });
+        let defaults = json!({ "triggers": { "mentions": false, "keywords": ["security"] } });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        let rt = resolved.respond_to.unwrap();
-        assert!(!rt.mentions);
-        assert_eq!(rt.keywords, vec!["deploy"]);
+        let t = resolved.triggers.unwrap();
+        assert!(t.mentions, "built-in default for mentions is true");
+        assert!(
+            t.keywords.is_empty(),
+            "built-in default for keywords is empty"
+        );
+        assert!(
+            !t.all_messages,
+            "built-in default for all_messages is false"
+        );
     }
 
     #[test]
-    fn respond_to_null_inherits_pack_default() {
-        // S1: Persona explicitly sets respond_to: null — fall through to pack default.
-        let persona = json!({ "respond_to": null });
-        let defaults = json!({ "respond_to": { "all_messages": true } });
+    fn triggers_null_inherits_pack_default() {
+        // Persona explicitly sets triggers: null — fall through to pack default.
+        let persona = json!({ "triggers": null });
+        let defaults = json!({ "triggers": { "all_messages": true } });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        let rt = resolved.respond_to.unwrap();
-        assert!(rt.all_messages);
+        let t = resolved.triggers.unwrap();
+        assert!(t.all_messages);
     }
 
     #[test]
-    fn respond_to_persona_explicit_empty_keywords_overrides_pack() {
+    fn triggers_persona_explicit_empty_keywords_overrides_pack() {
         // Persona explicitly sets `keywords: []`; pack default has `keywords: ["foo"]`.
         // Under shallow replacement, persona wins entirely — pack is ignored.
-        let persona = json!({ "respond_to": { "keywords": [] } });
-        let defaults = json!({ "respond_to": { "keywords": ["foo"] } });
+        let persona = json!({ "triggers": { "keywords": [] } });
+        let defaults = json!({ "triggers": { "keywords": ["foo"] } });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        let rt = resolved.respond_to.unwrap();
-        assert!(rt.keywords.is_empty());
+        let t = resolved.triggers.unwrap();
+        assert!(t.keywords.is_empty());
     }
 
     #[test]
-    fn respond_to_neither_side_returns_none() {
-        // Neither persona nor pack has respond_to — result is None.
+    fn triggers_neither_side_returns_none() {
+        // Neither persona nor pack has triggers — result is None.
         let persona = json!({ "model": "gpt-4o" });
         let resolved = resolve_persona_config(&persona, None);
-        assert!(resolved.respond_to.is_none());
+        assert!(resolved.triggers.is_none());
     }
 
-    // ── subscribe merge (S2) ─────────────────────────────────────────────────
+    // ── subscribe merge (Option<Vec<String>>) ────────────────────────────────
 
     #[test]
-    fn subscribe_null_falls_through_to_pack_default() {
-        // S2: persona subscribe: null → falls through to pack default.
+    fn subscribe_null_falls_through() {
+        // persona subscribe: null → falls through to pack default.
         let persona = json!({ "subscribe": null });
         let defaults = json!({ "subscribe": ["#general", "#alerts"] });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        assert_eq!(resolved.subscribe, vec!["#general", "#alerts"]);
+        assert_eq!(
+            resolved.subscribe,
+            Some(vec!["#general".to_owned(), "#alerts".to_owned()])
+        );
     }
 
     #[test]
-    fn subscribe_empty_overrides_pack_default() {
-        // S2: persona subscribe: [] → intentional "subscribe to nothing".
+    fn subscribe_empty_overrides() {
+        // persona subscribe: [] → intentional "subscribe to nothing" — Some([]).
         let persona = json!({ "subscribe": [] });
         let defaults = json!({ "subscribe": ["#general"] });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        assert!(resolved.subscribe.is_empty());
+        assert_eq!(resolved.subscribe, Some(vec![]));
     }
 
     #[test]
     fn subscribe_absent_falls_through_to_pack_default() {
-        // S2: persona has no subscribe field → falls through to pack default.
+        // persona has no subscribe field → falls through to pack default.
         let persona = json!({ "model": "gpt-4o" });
         let defaults = json!({ "subscribe": ["#security-reviews"] });
         let resolved = resolve_persona_config(&persona, Some(&defaults));
-        assert_eq!(resolved.subscribe, vec!["#security-reviews"]);
+        assert_eq!(
+            resolved.subscribe,
+            Some(vec!["#security-reviews".to_owned()])
+        );
+    }
+
+    #[test]
+    fn subscribe_absent_no_pack_is_none() {
+        // Neither persona nor pack has subscribe → None.
+        let persona = json!({ "model": "gpt-4o" });
+        let resolved = resolve_persona_config(&persona, None);
+        assert_eq!(resolved.subscribe, None);
     }
 }
diff --git a/crates/sprout-persona/src/pack.rs b/crates/sprout-persona/src/pack.rs
index e17774da3f..b1ee60b863 100644
--- a/crates/sprout-persona/src/pack.rs
+++ b/crates/sprout-persona/src/pack.rs
@@ -13,7 +13,6 @@
 ///   .mcp.json              ← optional shared MCP config
 ///   skills/                ← optional skills directory
 /// ```
-
 use std::{
     collections::HashMap,
     path::{Component, Path, PathBuf},
@@ -127,9 +126,10 @@ pub struct PackManifestData {
 /// 5. Read `.mcp.json` if present
 /// 6. Validate all paths resolve within `pack_root` (no path traversal)
 pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
-    let pack_root = pack_dir
-        .canonicalize()
-        .map_err(|e| PackError::Io { path: pack_dir.to_path_buf(), source: e })?;
+    let pack_root = pack_dir.canonicalize().map_err(|e| PackError::Io {
+        path: pack_dir.to_path_buf(),
+        source: e,
+    })?;
 
     // 1. Manifest
     let manifest_path = pack_root.join(".plugin").join("plugin.json");
@@ -181,7 +181,8 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
 
     // 4. Pack instructions
     // Size limit for text files (instructions, mcp config): generous but bounded.
-    let text_size_limit = (crate::persona::MAX_FRONTMATTER_BYTES + crate::persona::MAX_BODY_BYTES + 200) as u64;
+    let text_size_limit =
+        (crate::persona::MAX_FRONTMATTER_BYTES + crate::persona::MAX_BODY_BYTES + 200) as u64;
 
     let pack_instructions = match &manifest.pack_instructions {
         Some(rel) => {
@@ -192,15 +193,18 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
                     reason: format!("pack_instructions file not found: {rel}"),
                 });
             }
-            let meta = std::fs::metadata(&abs)
-                .map_err(|e| PackError::PersonaParse {
-                    path: abs.clone(),
-                    reason: format!("cannot stat file: {e}"),
-                })?;
+            let meta = std::fs::metadata(&abs).map_err(|e| PackError::PersonaParse {
+                path: abs.clone(),
+                reason: format!("cannot stat file: {e}"),
+            })?;
             if meta.len() > text_size_limit {
                 return Err(PackError::PersonaParse {
                     path: abs,
-                    reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                    reason: format!(
+                        "file too large: {} bytes (max {})",
+                        meta.len(),
+                        text_size_limit
+                    ),
                 });
             }
             Some(read_file(&abs)?)
@@ -209,15 +213,18 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
             // Conventional fallback — missing is fine when not explicitly declared.
             let path = pack_root.join("instructions.md");
             if path.exists() {
-                let meta = std::fs::metadata(&path)
-                    .map_err(|e| PackError::PersonaParse {
-                        path: path.clone(),
-                        reason: format!("cannot stat file: {e}"),
-                    })?;
+                let meta = std::fs::metadata(&path).map_err(|e| PackError::PersonaParse {
+                    path: path.clone(),
+                    reason: format!("cannot stat file: {e}"),
+                })?;
                 if meta.len() > text_size_limit {
                     return Err(PackError::PersonaParse {
                         path,
-                        reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                        reason: format!(
+                            "file too large: {} bytes (max {})",
+                            meta.len(),
+                            text_size_limit
+                        ),
                     });
                 }
                 Some(read_file(&path)?)
@@ -243,15 +250,18 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
                     reason: format!("mcp_config file not found: {rel}"),
                 });
             }
-            let meta = std::fs::metadata(&abs)
-                .map_err(|e| PackError::PersonaParse {
-                    path: abs.clone(),
-                    reason: format!("cannot stat file: {e}"),
-                })?;
+            let meta = std::fs::metadata(&abs).map_err(|e| PackError::PersonaParse {
+                path: abs.clone(),
+                reason: format!("cannot stat file: {e}"),
+            })?;
             if meta.len() > text_size_limit {
                 return Err(PackError::PersonaParse {
                     path: abs.clone(),
-                    reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                    reason: format!(
+                        "file too large: {} bytes (max {})",
+                        meta.len(),
+                        text_size_limit
+                    ),
                 });
             }
             Some(parse_mcp(read_file(&abs)?, &abs)?)
@@ -260,15 +270,18 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
             // Conventional fallback — missing is fine when not explicitly declared.
             let path = pack_root.join(".mcp.json");
             if path.exists() {
-                let meta = std::fs::metadata(&path)
-                    .map_err(|e| PackError::PersonaParse {
-                        path: path.clone(),
-                        reason: format!("cannot stat file: {e}"),
-                    })?;
+                let meta = std::fs::metadata(&path).map_err(|e| PackError::PersonaParse {
+                    path: path.clone(),
+                    reason: format!("cannot stat file: {e}"),
+                })?;
                 if meta.len() > text_size_limit {
                     return Err(PackError::PersonaParse {
                         path,
-                        reason: format!("file too large: {} bytes (max {})", meta.len(), text_size_limit),
+                        reason: format!(
+                            "file too large: {} bytes (max {})",
+                            meta.len(),
+                            text_size_limit
+                        ),
                     });
                 }
                 Some(parse_mcp(read_file(&path)?, &path)?)
@@ -281,7 +294,11 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
     // 6. Skills directory
     let skills_dir = {
         let path = pack_root.join("skills");
-        if path.is_dir() { Some(path) } else { None }
+        if path.is_dir() {
+            Some(path)
+        } else {
+            None
+        }
     };
 
     Ok(LoadedPack {
@@ -301,10 +318,7 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
 /// - Skills not listed in any persona's `skills:` → all personas (shared)
 ///
 /// Returns a map of `persona_name → Vec<skill_name>`.
-pub fn resolve_skills(
-    pack_dir: &Path,
-    personas: &[LoadedPersona],
-) -> HashMap<String, Vec<String>> {
+pub fn resolve_skills(pack_dir: &Path, personas: &[LoadedPersona]) -> HashMap<String, Vec<String>> {
     // Normalize a persona skill path (e.g. `"./skills/security-review/"`,
     // `"skills/search"`, `"web-search"`) to just the final path component
     // so it can be compared against bare `read_dir` entry names.
@@ -410,9 +424,10 @@ fn safe_resolve(pack_root: &Path, relative: &str) -> Result<PathBuf, PackError>
         return Ok(joined);
     }
 
-    let canonical = joined
-        .canonicalize()
-        .map_err(|e| PackError::Io { path: joined.clone(), source: e })?;
+    let canonical = joined.canonicalize().map_err(|e| PackError::Io {
+        path: joined.clone(),
+        source: e,
+    })?;
 
     // Step 3: must be inside pack_root.
     if !canonical.starts_with(pack_root) {
@@ -425,7 +440,10 @@ fn safe_resolve(pack_root: &Path, relative: &str) -> Result<PathBuf, PackError>
 // ── Parsing helpers ───────────────────────────────────────────────────────────
 
 fn read_file(path: &Path) -> Result<String, PackError> {
-    std::fs::read_to_string(path).map_err(|e| PackError::Io { path: path.to_path_buf(), source: e })
+    std::fs::read_to_string(path).map_err(|e| PackError::Io {
+        path: path.to_path_buf(),
+        source: e,
+    })
 }
 
 /// Parse a `.persona.md` file.
@@ -558,14 +576,13 @@ You are Berry, a fast and direct worker.
         let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
 
         fs::write(root.join("instructions.md"), "Pack-level instructions.").unwrap();
-        fs::write(
-            root.join(".mcp.json"),
-            r#"{"mcpServers": {}}"#,
-        )
-        .unwrap();
+        fs::write(root.join(".mcp.json"), r#"{"mcpServers": {}}"#).unwrap();
 
         let pack = load_pack(&root).unwrap();
-        assert_eq!(pack.pack_instructions.as_deref(), Some("Pack-level instructions."));
+        assert_eq!(
+            pack.pack_instructions.as_deref(),
+            Some("Pack-level instructions.")
+        );
         assert!(pack.shared_mcp_config.is_some());
     }
 
diff --git a/crates/sprout-persona/src/persona.rs b/crates/sprout-persona/src/persona.rs
new file mode 100644
index 0000000000..a03c5ead79
--- /dev/null
+++ b/crates/sprout-persona/src/persona.rs
@@ -0,0 +1,669 @@
+//! Core persona types and `.persona.md` parser.
+//!
+//! A `.persona.md` file is YAML frontmatter (between `---` delimiters)
+//! followed by a markdown body that becomes the system prompt.
+//!
+//! ```text
+//! ---
+//! name: my-bot
+//! display_name: My Bot
+//! description: Does things.
+//! ---
+//! You are My Bot. You do things.
+//! ```
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+// ── Safety limits ─────────────────────────────────────────────────────────────
+
+/// Maximum YAML frontmatter size in bytes (1 MiB).
+pub const MAX_FRONTMATTER_BYTES: usize = 1_048_576;
+
+/// Maximum persona prompt (markdown body) size in bytes (256 KiB).
+pub const MAX_BODY_BYTES: usize = 262_144;
+
+// ── Errors ────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, thiserror::Error)]
+pub enum PersonaError {
+    #[error("failed to read file: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("missing `---` frontmatter delimiters")]
+    NoFrontmatter,
+
+    #[error("frontmatter exceeds {MAX_FRONTMATTER_BYTES} bytes")]
+    FrontmatterTooLarge,
+
+    #[error("body exceeds {MAX_BODY_BYTES} bytes")]
+    BodyTooLarge,
+
+    #[error("file too large: {0}")]
+    TooLarge(String),
+
+    #[error("failed to parse YAML frontmatter: {0}")]
+    Yaml(#[from] serde_yaml::Error),
+
+    #[error("missing required field: {0}")]
+    MissingField(String),
+}
+
+// ── Supporting types ──────────────────────────────────────────────────────────
+
+/// Controls which messages trigger a response.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct RespondTo {
+    /// Respond when mentioned. Default: true.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mentions: Option<bool>,
+
+    /// Respond when any of these keywords appear.
+    #[serde(default)]
+    pub keywords: Vec<String>,
+
+    /// Respond to every message in subscribed channels. Default: false.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub all_messages: Option<bool>,
+}
+
+/// A single MCP server attached to this persona.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct McpServerConfig {
+    pub name: String,
+    pub command: String,
+
+    #[serde(default)]
+    pub args: Vec<String>,
+
+    #[serde(default)]
+    pub env: HashMap<String, String>,
+}
+
+/// Lifecycle hooks (paths are pack-relative).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case", deny_unknown_fields)]
+pub struct Hooks {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub on_start: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub on_stop: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub on_message: Option<String>,
+}
+
+// ── Core struct ───────────────────────────────────────────────────────────────
+
+/// Typed representation of a `.persona.md` file (V7 spec).
+///
+/// The `prompt` field holds the markdown body (system prompt).
+/// All other fields come from the YAML frontmatter.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct PersonaConfig {
+    // ── Identity ──────────────────────────────────────────────────────────
+    /// Machine name (slug). Required.
+    pub name: String,
+
+    /// Human-readable display name. Required.
+    pub display_name: String,
+
+    /// Pack-relative path to avatar image.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub avatar: Option<String>,
+
+    /// One-line description. Required.
+    pub description: String,
+
+    // ── OPS compatibility ─────────────────────────────────────────────────
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub version: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub author: Option<String>,
+
+    // ── Skills & MCP ──────────────────────────────────────────────────────
+    /// Pack-relative paths to skill directories.
+    #[serde(default)]
+    pub skills: Vec<String>,
+
+    /// Per-persona MCP server definitions.
+    #[serde(default)]
+    pub mcp_servers: Vec<McpServerConfig>,
+
+    // ── Behavioral config ─────────────────────────────────────────────────
+    /// Channel names to monitor.
+    ///
+    /// - `None` (omitted or `null`) → fall through to pack default
+    /// - `Some(vec![])` → intentional "subscribe to nothing"
+    /// - `Some(vec!["#general"])` → explicit channels
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub subscribe: Option<Vec<String>>,
+
+    /// Message matching triggers.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub triggers: Option<RespondTo>,
+
+    /// Model string in `"provider:model-id"` format.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub model: Option<String>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub temperature: Option<f64>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub max_context_tokens: Option<u64>,
+
+    /// Reply in-thread. Default: true.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub thread_replies: Option<bool>,
+
+    /// Broadcast replies to the channel. Default: false.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub broadcast_replies: Option<bool>,
+
+    // ── Hooks ─────────────────────────────────────────────────────────────
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub hooks: Option<Hooks>,
+
+    // ── System prompt (markdown body) ─────────────────────────────────────
+    /// The markdown body of the `.persona.md` file.
+    #[serde(default)]
+    pub prompt: String,
+}
+
+// ── Frontmatter-only intermediate ────────────────────────────────────────────
+
+/// Deserializes just the YAML frontmatter (no `prompt`).
+/// Unknown keys are rejected — typos cause parse errors instead of silent drops.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "snake_case", deny_unknown_fields)]
+struct Frontmatter {
+    name: Option<String>,
+    display_name: Option<String>,
+    avatar: Option<String>,
+    description: Option<String>,
+    version: Option<String>,
+    author: Option<String>,
+    #[serde(default)]
+    skills: Vec<String>,
+    #[serde(default)]
+    mcp_servers: Vec<McpServerConfig>,
+    #[serde(default)]
+    subscribe: Option<Vec<String>>,
+    #[serde(rename = "respond_to")]
+    triggers: Option<RespondTo>,
+    model: Option<String>,
+    temperature: Option<f64>,
+    max_context_tokens: Option<u64>,
+    thread_replies: Option<bool>,
+    broadcast_replies: Option<bool>,
+    hooks: Option<Hooks>,
+}
+
+// ── Parser ────────────────────────────────────────────────────────────────────
+
+/// Parse a `.persona.md` file into a [`PersonaConfig`].
+///
+/// Expects YAML frontmatter between `---` delimiters followed by a markdown
+/// body. The body becomes `PersonaConfig::prompt`.
+///
+/// # Limits
+/// - Frontmatter: max 1 MiB
+/// - Body: max 256 KiB
+pub fn parse_persona_md(content: &str) -> Result<PersonaConfig, PersonaError> {
+    let (fm_str, body) = split_frontmatter(content)?;
+
+    if fm_str.len() > MAX_FRONTMATTER_BYTES {
+        return Err(PersonaError::FrontmatterTooLarge);
+    }
+    if body.len() > MAX_BODY_BYTES {
+        return Err(PersonaError::BodyTooLarge);
+    }
+
+    let fm: Frontmatter = serde_yaml::from_str(fm_str)?;
+
+    let name = fm.name.ok_or(PersonaError::MissingField("name".into()))?;
+    let display_name = fm
+        .display_name
+        .ok_or(PersonaError::MissingField("display_name".into()))?;
+    let description = fm
+        .description
+        .ok_or(PersonaError::MissingField("description".into()))?;
+
+    // Fix #1: enforce non-empty required strings
+    if name.trim().is_empty() {
+        return Err(PersonaError::MissingField("name (empty)".into()));
+    }
+    if display_name.trim().is_empty() {
+        return Err(PersonaError::MissingField("display_name (empty)".into()));
+    }
+    if description.trim().is_empty() {
+        return Err(PersonaError::MissingField("description (empty)".into()));
+    }
+
+    Ok(PersonaConfig {
+        name,
+        display_name,
+        avatar: fm.avatar,
+        description,
+        version: fm.version,
+        author: fm.author,
+        skills: fm.skills,
+        mcp_servers: fm.mcp_servers,
+        subscribe: fm.subscribe,
+        triggers: fm.triggers,
+        model: fm.model,
+        temperature: fm.temperature,
+        max_context_tokens: fm.max_context_tokens,
+        thread_replies: fm.thread_replies,
+        broadcast_replies: fm.broadcast_replies,
+        hooks: fm.hooks,
+        prompt: body.to_string(),
+    })
+}
+
+/// Parse a `.persona.md` file from disk.
+pub fn parse_persona_file(path: &Path) -> Result<PersonaConfig, PersonaError> {
+    // Fix #4: check file size before reading to avoid large allocations
+    let metadata = std::fs::metadata(path)?;
+    if metadata.len() > MAX_FRONTMATTER_BYTES as u64 + MAX_BODY_BYTES as u64 + 100 {
+        return Err(PersonaError::TooLarge("file exceeds maximum size".into()));
+    }
+    let content = std::fs::read_to_string(path)?;
+    parse_persona_md(&content)
+}
+
+/// Split content into `(frontmatter_str, body_str)`.
+///
+/// Expects the file to begin with `---\n` and contain a second `---` line.
+/// The closing `---` must be on its own line: followed by `\n`, `\r\n`, or EOF.
+/// A line like `---junk` is NOT treated as a closing delimiter.
+pub fn split_frontmatter(content: &str) -> Result<(&str, &str), PersonaError> {
+    // Must start with "---"
+    let rest = content
+        .strip_prefix("---")
+        .ok_or(PersonaError::NoFrontmatter)?;
+
+    // Skip optional \r after the opening ---
+    let rest = rest.strip_prefix('\r').unwrap_or(rest);
+    let rest = rest.strip_prefix('\n').ok_or(PersonaError::NoFrontmatter)?;
+
+    // Find the closing --- that is on its own line (followed by \r\n, \n, or EOF).
+    // A line like "---junk" is not a valid delimiter — keep searching.
+    let mut search_from = 0;
+    let close = loop {
+        let pos = rest[search_from..]
+            .find("\n---")
+            .map(|p| p + search_from)
+            .ok_or(PersonaError::NoFrontmatter)?;
+        let after_dashes = pos + 4; // position after "\n---"
+        if after_dashes >= rest.len() {
+            // "---" at EOF — valid closing delimiter
+            break pos;
+        }
+        match rest.as_bytes().get(after_dashes) {
+            Some(b'\n') | Some(b'\r') => break pos, // valid delimiter
+            _ => {
+                search_from = after_dashes; // not a delimiter, keep looking
+                continue;
+            }
+        }
+    };
+
+    let fm_str = &rest[..close];
+    let after_close = &rest[close + 4..]; // skip "\n---"
+
+    // Skip optional \r\n or \n after closing ---
+    let body = after_close
+        .strip_prefix("\r\n")
+        .or_else(|| after_close.strip_prefix('\n'))
+        .unwrap_or(after_close);
+
+    Ok((fm_str, body))
+}
+
+// ── Model string helper ───────────────────────────────────────────────────────
+
+/// Split `"provider:model-id"` into `(Some("provider"), "model-id")`.
+///
+/// If there is no colon, returns `(None, full_string)`.
+pub fn split_model(model: &str) -> (Option<&str>, &str) {
+    match model.split_once(':') {
+        Some((provider, id)) => (Some(provider), id),
+        None => (None, model),
+    }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── Helpers ───────────────────────────────────────────────────────────
+
+    fn minimal() -> &'static str {
+        "---\nname: my-bot\ndisplay_name: My Bot\ndescription: Does things.\n---\n"
+    }
+
+    // ── Happy path ────────────────────────────────────────────────────────
+
+    #[test]
+    fn parse_minimal_valid() {
+        let p = parse_persona_md(minimal()).unwrap();
+        assert_eq!(p.name, "my-bot");
+        assert_eq!(p.display_name, "My Bot");
+        assert_eq!(p.description, "Does things.");
+        assert_eq!(p.prompt, "");
+    }
+
+    #[test]
+    fn parse_with_body() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\nYou are Bot.\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(p.prompt, "You are Bot.\n");
+    }
+
+    #[test]
+    fn empty_body_is_valid() {
+        let p = parse_persona_md(minimal()).unwrap();
+        assert_eq!(p.prompt, "");
+    }
+
+    #[test]
+    fn parse_full_fields() {
+        let src = indoc(
+            "---
+name: full-bot
+display_name: Full Bot
+avatar: assets/avatar.png
+description: Full featured.
+version: 1.2.3
+author: Tyler
+skills:
+  - skills/search
+mcp_servers:
+  - name: my-mcp
+    command: npx
+    args: ['-y', 'my-server']
+    env:
+      TOKEN: abc123
+subscribe:
+  - '#general'
+respond_to:
+  mentions: true
+  keywords: [hello, help]
+  all_messages: false
+model: openai:gpt-4o
+temperature: 0.7
+max_context_tokens: 8192
+thread_replies: true
+broadcast_replies: false
+hooks:
+  on_start: hooks/start.sh
+  on_stop: hooks/stop.sh
+---
+You are Full Bot.
+",
+        );
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(p.name, "full-bot");
+        assert_eq!(p.avatar.as_deref(), Some("assets/avatar.png"));
+        assert_eq!(p.skills, vec!["skills/search"]);
+        assert_eq!(p.mcp_servers.len(), 1);
+        assert_eq!(p.mcp_servers[0].name, "my-mcp");
+        assert_eq!(p.mcp_servers[0].env["TOKEN"], "abc123");
+        assert_eq!(p.subscribe, Some(vec!["#general".to_owned()]));
+        let rt = p.triggers.unwrap();
+        assert_eq!(rt.keywords, vec!["hello", "help"]);
+        assert_eq!(p.model.as_deref(), Some("openai:gpt-4o"));
+        assert_eq!(p.temperature, Some(0.7));
+        assert_eq!(p.max_context_tokens, Some(8192));
+        let hooks = p.hooks.unwrap();
+        assert_eq!(hooks.on_start.as_deref(), Some("hooks/start.sh"));
+        assert_eq!(p.prompt, "You are Full Bot.\n");
+    }
+
+    #[test]
+    fn unknown_frontmatter_keys_error() {
+        // deny_unknown_fields: typos in frontmatter keys cause a parse error.
+        let src =
+            "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nunknown_key: surprise\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(matches!(err, PersonaError::Yaml(_)), "got: {err}");
+    }
+
+    #[test]
+    fn unknown_hook_key_errors() {
+        // deny_unknown_fields on Hooks means a typo like "on_init" is caught.
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nhooks:\n  on_start: hooks/start.sh\n  on_init: hooks/init.sh\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(matches!(err, PersonaError::Yaml(_)), "got: {err}");
+    }
+
+    // ── Missing required fields ───────────────────────────────────────────
+
+    #[test]
+    fn missing_name_errors() {
+        let src = "---\ndisplay_name: Bot\ndescription: A bot.\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f == "name"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn missing_display_name_errors() {
+        let src = "---\nname: bot\ndescription: A bot.\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f == "display_name"),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn missing_description_errors() {
+        let src = "---\nname: bot\ndisplay_name: Bot\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f == "description"),
+            "got: {err}"
+        );
+    }
+
+    // ── Empty required fields (Fix #1) ────────────────────────────────────
+
+    #[test]
+    fn empty_name_errors() {
+        let src = "---\nname: \"\"\ndisplay_name: Bot\ndescription: A bot.\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f.contains("name")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn whitespace_only_name_errors() {
+        let src = "---\nname: \"   \"\ndisplay_name: Bot\ndescription: A bot.\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f.contains("name")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn empty_display_name_errors() {
+        let src = "---\nname: bot\ndisplay_name: \"\"\ndescription: A bot.\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f.contains("display_name")),
+            "got: {err}"
+        );
+    }
+
+    #[test]
+    fn empty_description_errors() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: \"\"\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(&err, PersonaError::MissingField(f) if f.contains("description")),
+            "got: {err}"
+        );
+    }
+
+    // ── Delimiter errors ──────────────────────────────────────────────────
+
+    #[test]
+    fn no_frontmatter_delimiters_errors() {
+        let err = parse_persona_md("Just plain markdown.").unwrap_err();
+        assert!(matches!(err, PersonaError::NoFrontmatter));
+    }
+
+    #[test]
+    fn missing_closing_delimiter_errors() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(matches!(err, PersonaError::NoFrontmatter));
+    }
+
+    #[test]
+    fn closing_delimiter_with_trailing_junk_is_not_valid() {
+        // "---junk" must NOT be treated as a closing delimiter.
+        // The parser should keep searching and ultimately return NoFrontmatter.
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---junk\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(
+            matches!(err, PersonaError::NoFrontmatter),
+            "expected NoFrontmatter, got: {err}"
+        );
+    }
+
+    #[test]
+    fn closing_delimiter_with_junk_skipped_finds_real_close() {
+        // A "---junk" line inside a YAML block scalar should be skipped;
+        // the real "---" on its own line should still be found.
+        // Use a literal block scalar (|) so "---junk" is valid YAML content.
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: |\n  some text\n  ---junk\n---\nBody here.\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(p.name, "bot");
+        assert_eq!(p.prompt, "Body here.\n");
+    }
+
+    // ── Malformed YAML ────────────────────────────────────────────────────
+
+    #[test]
+    fn malformed_yaml_errors() {
+        let src = "---\n: bad: yaml: here\n---\n";
+        let err = parse_persona_md(src).unwrap_err();
+        assert!(matches!(err, PersonaError::Yaml(_)));
+    }
+
+    // ── Size limits ───────────────────────────────────────────────────────
+
+    #[test]
+    fn frontmatter_too_large_errors() {
+        // Build a frontmatter that exceeds 1 MiB
+        let big = "x".repeat(MAX_FRONTMATTER_BYTES + 1);
+        let src = format!("---\n{big}\n---\n");
+        let err = parse_persona_md(&src).unwrap_err();
+        assert!(matches!(err, PersonaError::FrontmatterTooLarge));
+    }
+
+    #[test]
+    fn body_too_large_errors() {
+        let big = "x".repeat(MAX_BODY_BYTES + 1);
+        let src = format!("---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\n{big}");
+        let err = parse_persona_md(&src).unwrap_err();
+        assert!(matches!(err, PersonaError::BodyTooLarge));
+    }
+
+    // ── split_model ───────────────────────────────────────────────────────
+
+    #[test]
+    fn split_model_with_colon() {
+        let (provider, id) = split_model("openai:gpt-4o");
+        assert_eq!(provider, Some("openai"));
+        assert_eq!(id, "gpt-4o");
+    }
+
+    #[test]
+    fn split_model_without_colon() {
+        let (provider, id) = split_model("gpt-4o");
+        assert_eq!(provider, None);
+        assert_eq!(id, "gpt-4o");
+    }
+
+    #[test]
+    fn split_model_multiple_colons_uses_first() {
+        let (provider, id) = split_model("databricks:gpt-5:preview");
+        assert_eq!(provider, Some("databricks"));
+        assert_eq!(id, "gpt-5:preview");
+    }
+
+    // ── Subscribe three-state semantics (S2) ─────────────────────────────
+
+    #[test]
+    fn parse_subscribe_null_is_none() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe: null\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(p.subscribe, None, "YAML null should deserialize to None");
+    }
+
+    #[test]
+    fn parse_subscribe_empty_is_some_empty() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe: []\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(
+            p.subscribe,
+            Some(vec![]),
+            "YAML [] should deserialize to Some(empty vec)"
+        );
+    }
+
+    #[test]
+    fn parse_subscribe_omitted_is_none() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(p.subscribe, None, "omitted subscribe should be None");
+    }
+
+    #[test]
+    fn parse_triggers_field_exists() {
+        // `triggers:` in YAML (via serde rename = "respond_to" the YAML key is
+        // still `respond_to`) — verify the field parses and round-trips correctly.
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nrespond_to:\n  mentions: true\n  keywords: [hello, help]\n  all_messages: false\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        let t = p.triggers.expect("triggers should be Some");
+        assert_eq!(t.mentions, Some(true));
+        assert_eq!(t.keywords, vec!["hello", "help"]);
+        assert_eq!(t.all_messages, Some(false));
+    }
+
+    #[test]
+    fn parse_subscribe_with_channels() {
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe:\n  - \"#general\"\n  - \"#random\"\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        assert_eq!(
+            p.subscribe,
+            Some(vec!["#general".to_owned(), "#random".to_owned()])
+        );
+    }
+
+    // ── Helpers ───────────────────────────────────────────────────────────
+
+    /// Trim leading newline from indented string literals.
+    fn indoc(s: &str) -> &str {
+        s.strip_prefix('\n').unwrap_or(s)
+    }
+}
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 14d3aed538..4f3cb59614 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -117,7 +117,12 @@ pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackErro
 
     let mut personas = Vec::with_capacity(loaded.personas.len());
     for lp in &loaded.personas {
-        personas.push(resolve_one_persona(lp, pack_version, pack_instructions, shared_mcp));
+        personas.push(resolve_one_persona(
+            lp,
+            pack_version,
+            pack_instructions,
+            shared_mcp,
+        ));
     }
 
     Ok(ResolvedPack {
@@ -242,10 +247,7 @@ fn merge_mcp_servers(
     // 1. Pack-level shared servers from .mcp.json
     if let Some(shared) = shared_mcp {
         // .mcp.json format: { "mcpServers": { "name": { "command": ..., "args": [...], "env": {...} } } }
-        if let Some(servers_obj) = shared
-            .get("mcpServers")
-            .and_then(|v| v.as_object())
-        {
+        if let Some(servers_obj) = shared.get("mcpServers").and_then(|v| v.as_object()) {
             for (name, config) in servers_obj {
                 if let Some(server) = parse_mcp_server_config(name, config) {
                     by_name.insert(name.clone(), server);
@@ -758,11 +760,13 @@ mod tests {
         std::fs::write(
             dir.join("agents/a.persona.md"),
             "---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\nAlpha prompt.\n",
-        ).unwrap();
+        )
+        .unwrap();
         std::fs::write(
             dir.join("agents/b.persona.md"),
             "---\nname: beta\ndisplay_name: Beta\ndescription: Second.\n---\nBeta prompt.\n",
-        ).unwrap();
+        )
+        .unwrap();
 
         let p = resolve_persona_by_name(dir, "beta").unwrap();
         assert_eq!(p.name, "beta");
@@ -780,11 +784,13 @@ mod tests {
         std::fs::write(
             dir.join(".plugin/plugin.json"),
             r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
-        ).unwrap();
+        )
+        .unwrap();
         std::fs::write(
             dir.join("agents/a.persona.md"),
             "---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\n",
-        ).unwrap();
+        )
+        .unwrap();
 
         let err = resolve_persona_by_name(dir, "nonexistent").unwrap_err();
         assert!(matches!(err, PackError::PersonaNotFound(_)));
@@ -802,11 +808,13 @@ mod tests {
         std::fs::write(
             dir.join(".plugin/plugin.json"),
             r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
-        ).unwrap();
+        )
+        .unwrap();
         std::fs::write(
             dir.join("agents/a.persona.md"),
             "---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: openai:gpt-4o\n---\n",
-        ).unwrap();
+        )
+        .unwrap();
 
         let pack = resolve_pack(dir).unwrap();
         let p = &pack.personas[0];
@@ -824,11 +832,13 @@ mod tests {
         std::fs::write(
             dir.join(".plugin/plugin.json"),
             r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
-        ).unwrap();
+        )
+        .unwrap();
         std::fs::write(
             dir.join("agents/a.persona.md"),
             "---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: gpt-4o\n---\n",
-        ).unwrap();
+        )
+        .unwrap();
 
         let pack = resolve_pack(dir).unwrap();
         let p = &pack.personas[0];
diff --git a/crates/sprout-persona/src/validate.rs b/crates/sprout-persona/src/validate.rs
new file mode 100644
index 0000000000..f0de3bb786
--- /dev/null
+++ b/crates/sprout-persona/src/validate.rs
@@ -0,0 +1,1089 @@
+//! Pack validation (`sprout pack validate`).
+//!
+//! Architecture: the validator delegates all structural checks to `load_pack()`.
+//! If loading succeeds, the pack is structurally valid by definition — no
+//! duplicate parsing, no contract drift.
+//!
+//! On top of the load, advisory checks inspect raw files for things the typed
+//! parsers silently drop (unknown keys, naming conventions).
+//!
+//! Exit semantics: errors are hard failures, warnings are advisory.
+
+use std::collections::HashSet;
+use std::path::Path;
+
+use crate::pack;
+
+// ── Diagnostics ──────────────────────────────────────────────────────────────
+
+/// A single validation finding.
+#[derive(Debug, Clone)]
+pub enum ValidationDiagnostic {
+    Error(String),
+    Warning(String),
+}
+
+impl std::fmt::Display for ValidationDiagnostic {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Error(msg) => write!(f, "ERROR: {msg}"),
+            Self::Warning(msg) => write!(f, "WARN:  {msg}"),
+        }
+    }
+}
+
+/// Result of validating a pack.
+#[derive(Debug, Default)]
+pub struct ValidationReport {
+    pub diagnostics: Vec<ValidationDiagnostic>,
+}
+
+impl ValidationReport {
+    pub fn error(&mut self, msg: impl Into<String>) {
+        self.diagnostics
+            .push(ValidationDiagnostic::Error(msg.into()));
+    }
+
+    pub fn warn(&mut self, msg: impl Into<String>) {
+        self.diagnostics
+            .push(ValidationDiagnostic::Warning(msg.into()));
+    }
+
+    pub fn has_errors(&self) -> bool {
+        self.diagnostics
+            .iter()
+            .any(|d| matches!(d, ValidationDiagnostic::Error(_)))
+    }
+
+    pub fn has_warnings(&self) -> bool {
+        self.diagnostics
+            .iter()
+            .any(|d| matches!(d, ValidationDiagnostic::Warning(_)))
+    }
+
+    /// Exit code: 0 = clean, 1 = errors, 2 = warnings only.
+    pub fn exit_code(&self) -> i32 {
+        if self.has_errors() {
+            1
+        } else if self.has_warnings() {
+            2
+        } else {
+            0
+        }
+    }
+}
+
+impl std::fmt::Display for ValidationReport {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.diagnostics.is_empty() {
+            writeln!(f, "✓ Pack is valid.")?;
+        } else {
+            for d in &self.diagnostics {
+                writeln!(f, "  {d}")?;
+            }
+            let errors = self
+                .diagnostics
+                .iter()
+                .filter(|d| matches!(d, ValidationDiagnostic::Error(_)))
+                .count();
+            let warnings = self
+                .diagnostics
+                .iter()
+                .filter(|d| matches!(d, ValidationDiagnostic::Warning(_)))
+                .count();
+            writeln!(f, "\n{errors} error(s), {warnings} warning(s).")?;
+        }
+        Ok(())
+    }
+}
+
+// ── Known field sets ─────────────────────────────────────────────────────────
+
+/// Known top-level keys in `plugin.json`.
+const KNOWN_MANIFEST_KEYS: &[&str] = &[
+    // OPS standard fields
+    "$schema",
+    "id",
+    "name",
+    "version",
+    "description",
+    "author",
+    "license",
+    "homepage",
+    "repository",
+    "keywords",
+    "engines",
+    // Persona pack extensions
+    "personas",
+    "defaults",
+    "pack_instructions",
+    "hooks_config",
+    "mcp_config",
+];
+
+/// Valid keys in the `defaults` block and persona behavioral config.
+const KNOWN_BEHAVIORAL_KEYS: &[&str] = &[
+    "subscribe",
+    "triggers",
+    "respond_to", // legacy alias — still accepted
+    "model",
+    "temperature",
+    "max_context_tokens",
+    "thread_replies",
+    "broadcast_replies",
+];
+
+/// Valid sub-keys in `respond_to`.
+const KNOWN_RESPOND_TO_KEYS: &[&str] = &["mentions", "keywords", "all_messages"];
+
+// ── Pack validation ──────────────────────────────────────────────────────────
+
+/// Validate a persona pack directory.
+///
+/// Step 1: delegate all structural validation to `load_pack()`. If loading
+/// fails, the pack is broken — report the error and return.
+///
+/// Step 2: run advisory checks on the loaded pack. These inspect raw files
+/// for naming drift and unknown manifest keys. Unknown manifest keys are
+/// reported as warnings (likely typos); naming mismatches are also warnings.
+pub fn validate_pack(pack_dir: &Path) -> ValidationReport {
+    let mut report = ValidationReport::default();
+
+    // Step 1: structural validation via the loader.
+    let loaded = match pack::load_pack(pack_dir) {
+        Ok(pack) => pack,
+        Err(e) => {
+            report.error(format!("pack failed to load: {e}"));
+            return report;
+        }
+    };
+
+    // Step 2: semantic checks on the loaded pack.
+    semantic_check_personas(&loaded, &mut report);
+
+    // Step 3: advisory checks on raw files.
+    advisory_check_manifest_keys(pack_dir, &mut report);
+    advisory_check_respond_to_types(pack_dir, &mut report);
+    advisory_check_skill_names(pack_dir, &loaded, &mut report);
+
+    report
+}
+
+// ── Semantic: persona-level checks ───────────────────────────────────────────
+
+/// Validate a persona `name` field: `[a-zA-Z0-9_-]+`, max 64 chars.
+fn validate_persona_name(name: &str, report: &mut ValidationReport) {
+    const MAX_NAME_LEN: usize = 64;
+    if name.len() > MAX_NAME_LEN {
+        report.error(format!(
+            "persona name \"{name}\" exceeds {MAX_NAME_LEN} characters (got {})",
+            name.len()
+        ));
+    }
+    if !name
+        .chars()
+        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+    {
+        report.error(format!(
+            "persona name \"{name}\" contains invalid characters (allowed: [a-zA-Z0-9_-])"
+        ));
+    }
+}
+
+/// Check for zero-persona packs, duplicate persona names, and name validity.
+/// These are hard errors — the pack is logically broken.
+fn semantic_check_personas(loaded: &pack::LoadedPack, report: &mut ValidationReport) {
+    // Zero personas: a pack with no personas is useless.
+    if loaded.personas.is_empty() {
+        report.error("pack contains zero personas");
+        return; // no point checking duplicates or names
+    }
+
+    // Duplicate persona names: names must be unique within a pack.
+    let mut seen = HashSet::new();
+    for persona in &loaded.personas {
+        if !seen.insert(&persona.name) {
+            report.error(format!("duplicate persona name \"{}\"", persona.name));
+        }
+        // Name character and length validation.
+        validate_persona_name(&persona.name, report);
+    }
+}
+
+// ── Advisory: respond_to type validation ────────────────────────────────────
+
+/// Check `defaults.respond_to` sub-key types in the raw `plugin.json`.
+///
+/// The typed parser (serde) already rejects wrong types in persona frontmatter,
+/// but the defaults block deserializes more permissively. This advisory check
+/// inspects the raw JSON to catch type mismatches early with clear messages.
+fn advisory_check_respond_to_types(pack_dir: &Path, report: &mut ValidationReport) {
+    let manifest_path = pack_dir.join(".plugin").join("plugin.json");
+    let content = match std::fs::read_to_string(&manifest_path) {
+        Ok(c) => c,
+        Err(_) => return,
+    };
+    let json: serde_json::Value = match serde_json::from_str(&content) {
+        Ok(v) => v,
+        Err(_) => return,
+    };
+
+    // Check defaults.respond_to
+    if let Some(defaults) = json.get("defaults").and_then(|v| v.as_object()) {
+        if let Some(rt) = defaults.get("respond_to") {
+            check_respond_to_value(rt, "defaults.respond_to", report);
+        }
+    }
+}
+
+/// Validate respond_to sub-key types.
+/// - `mentions` must be bool
+/// - `keywords` must be array of strings
+/// - `all_messages` must be bool
+fn check_respond_to_value(rt: &serde_json::Value, context: &str, report: &mut ValidationReport) {
+    let obj = match rt.as_object() {
+        Some(o) => o,
+        None => {
+            report.error(format!(
+                "{context}: expected object, got {}",
+                value_type_name(rt)
+            ));
+            return;
+        }
+    };
+
+    if let Some(v) = obj.get("mentions") {
+        if !v.is_boolean() {
+            report.error(format!(
+                "{context}.mentions: expected bool, got {}",
+                value_type_name(v)
+            ));
+        }
+    }
+
+    if let Some(v) = obj.get("keywords") {
+        match v.as_array() {
+            Some(arr) => {
+                for (i, item) in arr.iter().enumerate() {
+                    if !item.is_string() {
+                        report.error(format!(
+                            "{context}.keywords[{i}]: expected string, got {}",
+                            value_type_name(item)
+                        ));
+                    }
+                }
+            }
+            None => {
+                report.error(format!(
+                    "{context}.keywords: expected array, got {}",
+                    value_type_name(v)
+                ));
+            }
+        }
+    }
+
+    if let Some(v) = obj.get("all_messages") {
+        if !v.is_boolean() {
+            report.error(format!(
+                "{context}.all_messages: expected bool, got {}",
+                value_type_name(v)
+            ));
+        }
+    }
+}
+
+/// Human-readable JSON value type name.
+fn value_type_name(v: &serde_json::Value) -> &'static str {
+    match v {
+        serde_json::Value::Null => "null",
+        serde_json::Value::Bool(_) => "bool",
+        serde_json::Value::Number(_) => "number",
+        serde_json::Value::String(_) => "string",
+        serde_json::Value::Array(_) => "array",
+        serde_json::Value::Object(_) => "object",
+    }
+}
+
+// ── Advisory: unknown manifest keys ─────────────────────────────────────────
+
+/// Check `plugin.json` for unknown top-level keys and unknown keys in
+/// `defaults` / `defaults.respond_to`. Emits warnings (likely typos).
+fn advisory_check_manifest_keys(pack_dir: &Path, report: &mut ValidationReport) {
+    let manifest_path = pack_dir.join(".plugin").join("plugin.json");
+    let content = match std::fs::read_to_string(&manifest_path) {
+        Ok(c) => c,
+        Err(_) => return, // load_pack already caught this
+    };
+    let json: serde_json::Value = match serde_json::from_str(&content) {
+        Ok(v) => v,
+        Err(_) => return, // load_pack already caught this
+    };
+    let obj = match json.as_object() {
+        Some(o) => o,
+        None => return,
+    };
+
+    // Top-level unknown keys.
+    let known_manifest: HashSet<&str> = KNOWN_MANIFEST_KEYS.iter().copied().collect();
+    for key in obj.keys() {
+        if !known_manifest.contains(key.as_str()) {
+            report.warn(format!("plugin.json unknown key \"{key}\""));
+        }
+    }
+
+    // Unknown keys in `defaults`.
+    if let Some(defaults) = obj.get("defaults").and_then(|v| v.as_object()) {
+        let known_behavioral: HashSet<&str> = KNOWN_BEHAVIORAL_KEYS.iter().copied().collect();
+        for key in defaults.keys() {
+            if !known_behavioral.contains(key.as_str()) {
+                report.warn(format!("plugin.json defaults: unknown key \"{key}\""));
+            }
+        }
+
+        // Unknown keys in `defaults.respond_to`.
+        if let Some(rt) = defaults.get("respond_to").and_then(|v| v.as_object()) {
+            let known_rt: HashSet<&str> = KNOWN_RESPOND_TO_KEYS.iter().copied().collect();
+            for key in rt.keys() {
+                if !known_rt.contains(key.as_str()) {
+                    report.warn(format!(
+                        "plugin.json defaults.respond_to: unknown key \"{key}\""
+                    ));
+                }
+            }
+        }
+    }
+}
+
+// ── Advisory: skill naming conventions ──────────────────────────────────────
+
+/// For each skill directory referenced by a loaded persona, check that the
+/// SKILL.md `name:` field matches the directory name. Emits warnings.
+fn advisory_check_skill_names(
+    pack_dir: &Path,
+    loaded: &pack::LoadedPack,
+    report: &mut ValidationReport,
+) {
+    // Collect all skill paths referenced by any persona.
+    let mut skill_paths: Vec<std::path::PathBuf> = Vec::new();
+    for persona in &loaded.personas {
+        for skill_ref in &persona.skills {
+            // Normalize: strip trailing slash, take final component.
+            let skill_name = std::path::Path::new(skill_ref.trim_end_matches('/'))
+                .file_name()
+                .and_then(|n| n.to_str())
+                .unwrap_or(skill_ref.as_str())
+                .to_owned();
+            let candidate = pack_dir.join("skills").join(&skill_name);
+            if candidate.is_dir() && !skill_paths.contains(&candidate) {
+                skill_paths.push(candidate);
+            }
+        }
+    }
+
+    // Also check skills dir for any directories not claimed by personas.
+    let skills_dir = pack_dir.join("skills");
+    if skills_dir.is_dir() {
+        if let Ok(entries) = std::fs::read_dir(&skills_dir) {
+            for entry in entries.flatten() {
+                if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
+                    let p = entry.path();
+                    if !skill_paths.contains(&p) {
+                        skill_paths.push(p);
+                    }
+                }
+            }
+        }
+    }
+
+    for skill_dir in &skill_paths {
+        let skill_md = skill_dir.join("SKILL.md");
+        if !skill_md.exists() {
+            continue; // load_pack handles missing SKILL.md if it's required
+        }
+
+        let content = match std::fs::read_to_string(&skill_md) {
+            Ok(c) => c,
+            Err(_) => continue,
+        };
+
+        let (fm_str, _) = match crate::persona::split_frontmatter(&content).ok() {
+            Some(parts) => parts,
+            None => continue,
+        };
+
+        let yaml_val: serde_yaml::Value = match serde_yaml::from_str(fm_str) {
+            Ok(v) => v,
+            Err(_) => continue,
+        };
+
+        let mapping = match yaml_val.as_mapping() {
+            Some(m) => m,
+            None => continue,
+        };
+
+        if let Some(name_val) = mapping.get(serde_yaml::Value::String("name".into())) {
+            if let Some(name_str) = name_val.as_str() {
+                if let Some(dir_name) = skill_dir.file_name().and_then(|n| n.to_str()) {
+                    if name_str != dir_name {
+                        let label = skill_dir
+                            .strip_prefix(pack_dir)
+                            .unwrap_or(skill_dir)
+                            .display()
+                            .to_string();
+                        report.warn(format!(
+                            "skill {label}: name \"{name_str}\" differs from directory name \"{dir_name}\""
+                        ));
+                    }
+                }
+            }
+        }
+    }
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ── ValidationReport unit tests ──────────────────────────────────────────
+
+    #[test]
+    fn exit_code_clean() {
+        let report = ValidationReport::default();
+        assert_eq!(report.exit_code(), 0);
+    }
+
+    #[test]
+    fn exit_code_errors() {
+        let mut report = ValidationReport::default();
+        report.error("bad");
+        assert_eq!(report.exit_code(), 1);
+    }
+
+    #[test]
+    fn exit_code_warnings_only() {
+        let mut report = ValidationReport::default();
+        report.warn("meh");
+        assert_eq!(report.exit_code(), 2);
+    }
+
+    // ── Filesystem integration tests ─────────────────────────────────────────
+
+    /// Minimal valid pack: load succeeds, no advisory issues.
+    #[test]
+    fn validate_pack_minimal_valid() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.minimal",
+                "name": "Minimal Pack",
+                "version": "0.1.0",
+                "personas": ["agents/test.persona.md"]
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/test.persona.md"),
+            "---\nname: \"test\"\ndisplay_name: \"Test Agent\"\ndescription: \"A test agent\"\n---\n\nYou are a test agent.\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(
+            !report.has_errors(),
+            "expected clean validation, got: {report}"
+        );
+        assert_eq!(report.exit_code(), 0);
+    }
+
+    /// Missing plugin.json: load_pack fails → error reported.
+    #[test]
+    fn validate_pack_missing_manifest() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors());
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("plugin.json") || msg.contains("load"),
+            "got: {msg}"
+        );
+    }
+
+    /// Path traversal in personas list: load_pack rejects it → error.
+    #[test]
+    fn validate_pack_persona_path_traversal() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.evil",
+                "name": "Evil Pack",
+                "version": "0.1.0",
+                "personas": ["../../etc/passwd"]
+            }"#,
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors());
+    }
+
+    /// Unknown key in defaults: advisory check emits warning.
+    #[test]
+    fn validate_pack_unknown_defaults_key() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.typo",
+                "name": "Typo Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "defaults": { "temprature": 0.5 }
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(!report.has_errors(), "advisory checks should not be errors");
+        assert!(report.has_warnings(), "expected warning for unknown key");
+        let msg = format!("{report}");
+        assert!(msg.contains("temprature"), "got: {msg}");
+    }
+
+    /// OPS standard fields should NOT trigger unknown key errors.
+    #[test]
+    fn validate_pack_ops_fields_accepted() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "$schema": "https://open-plugin-spec.org/schema/v1/plugin.json",
+                "id": "com.test.ops",
+                "name": "OPS Fields Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "license": "MIT",
+                "homepage": "https://example.com",
+                "repository": "https://github.com/example/pack",
+                "keywords": ["test"],
+                "engines": { "sprout": ">=0.9.0" }
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        let key_errors: Vec<_> = report
+            .diagnostics
+            .iter()
+            .filter(|d| {
+                if let ValidationDiagnostic::Error(msg) = d {
+                    msg.contains("unknown key")
+                } else {
+                    false
+                }
+            })
+            .collect();
+        assert!(
+            key_errors.is_empty(),
+            "OPS fields should not trigger unknown key errors: {key_errors:?}"
+        );
+    }
+
+    /// Unknown top-level key in plugin.json: advisory check emits warning.
+    #[test]
+    fn validate_pack_unknown_manifest_key() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.bogus",
+                "name": "Bogus Key Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "totally_made_up": true
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(!report.has_errors(), "advisory checks should not be errors");
+        assert!(report.has_warnings(), "expected warning for unknown key");
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("totally_made_up"),
+            "expected unknown key warning for 'totally_made_up', got: {msg}"
+        );
+    }
+
+    /// Persona missing required fields: load_pack fails → error.
+    #[test]
+    fn validate_pack_persona_missing_required_fields() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"0.1.0","personas":["agents/t.persona.md"]}"#,
+        )
+        .unwrap();
+        // Missing display_name and description.
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: \"bad\"\n---\nNo display_name or description.\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors());
+    }
+
+    /// Persona with no frontmatter: load_pack fails → error.
+    #[test]
+    fn validate_pack_persona_no_frontmatter() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.bad-fm",
+                "name": "Bad Frontmatter Pack",
+                "version": "0.1.0",
+                "personas": ["agents/broken.persona.md"]
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/broken.persona.md"),
+            "This file has no frontmatter at all.\nJust plain markdown.\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors());
+    }
+
+    /// Persona with leading whitespace before ---: load_pack fails → error.
+    #[test]
+    fn validate_pack_persona_leading_whitespace() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.ws",
+                "name": "Whitespace Pack",
+                "version": "0.1.0",
+                "personas": ["agents/ws.persona.md"]
+            }"#,
+        )
+        .unwrap();
+
+        std::fs::write(
+            dir.join("agents/ws.persona.md"),
+            "\n---\nname: \"test\"\ndisplay_name: \"Test\"\ndescription: \"A test\"\n---\n\nPrompt.\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(
+            report.has_errors(),
+            "expected error for leading whitespace before ---, got clean validation"
+        );
+    }
+
+    /// Unknown frontmatter key in persona: advisory check emits error.
+    #[test]
+    fn validate_pack_persona_unknown_key() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"0.1.0","personas":["agents/t.persona.md"]}"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\nzomg_unknown: true\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors());
+        let msg = format!("{report}");
+        assert!(msg.contains("zomg_unknown"), "got: {msg}");
+    }
+
+    /// Skill name mismatch: advisory check emits warning.
+    #[test]
+    fn validate_pack_skill_name_mismatch_warns() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+        std::fs::create_dir_all(dir.join("skills/code-review")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"0.1.0","personas":["agents/t.persona.md"]}"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\nskills:\n  - skills/code-review\n---\n",
+        )
+        .unwrap();
+        // SKILL.md name doesn't match directory name "code-review".
+        std::fs::write(
+            dir.join("skills/code-review/SKILL.md"),
+            "---\nname: code_review\ndescription: Reviews code.\n---\nDoes code review.\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(!report.has_errors(), "should be no errors, got: {report}");
+        assert!(report.has_warnings(), "expected naming mismatch warning");
+        let msg = format!("{report}");
+        assert!(msg.contains("code_review"), "got: {msg}");
+    }
+
+    // ── Semantic: zero-persona and duplicate-name checks ─────────────────────
+
+    /// Zero personas in manifest → hard error.
+    #[test]
+    fn validate_zero_personas_error() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.empty",
+                "name": "Empty Pack",
+                "version": "0.1.0",
+                "personas": []
+            }"#,
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors(), "zero-persona pack should be an error");
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("zero personas"),
+            "error should mention zero personas, got: {msg}"
+        );
+    }
+
+    /// Duplicate persona names → hard error.
+    #[test]
+    fn validate_duplicate_persona_names_error() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.dupes",
+                "name": "Dupe Pack",
+                "version": "0.1.0",
+                "personas": ["agents/a.persona.md", "agents/b.persona.md"]
+            }"#,
+        )
+        .unwrap();
+        // Both personas have the same name "bot".
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: bot\ndisplay_name: Bot A\ndescription: First bot.\n---\n",
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/b.persona.md"),
+            "---\nname: bot\ndisplay_name: Bot B\ndescription: Second bot.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors(), "duplicate names should be an error");
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("duplicate persona name") && msg.contains("bot"),
+            "error should mention duplicate name 'bot', got: {msg}"
+        );
+    }
+
+    /// Unique persona names → no duplicate error.
+    #[test]
+    fn validate_unique_persona_names_ok() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.unique",
+                "name": "Unique Pack",
+                "version": "0.1.0",
+                "personas": ["agents/a.persona.md", "agents/b.persona.md"]
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/a.persona.md"),
+            "---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\n",
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/b.persona.md"),
+            "---\nname: beta\ndisplay_name: Beta\ndescription: Second.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(
+            !report.has_errors(),
+            "unique names should not cause errors, got: {report}"
+        );
+    }
+
+    // ── Semantic: name character and length validation ────────────────────────
+
+    /// Persona name with spaces or slashes → hard error.
+    #[test]
+    fn validate_name_invalid_chars() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"0.1.0","personas":["agents/t.persona.md"]}"#,
+        )
+        .unwrap();
+        // Name contains a space — invalid.
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: \"my bot\"\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors(), "name with spaces should be an error");
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("invalid characters"),
+            "error should mention invalid characters, got: {msg}"
+        );
+    }
+
+    /// Persona name with 65+ characters → hard error.
+    #[test]
+    fn validate_name_too_long() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{"id":"t","name":"T","version":"0.1.0","personas":["agents/t.persona.md"]}"#,
+        )
+        .unwrap();
+        // 65-character name — one over the limit.
+        let long_name = "a".repeat(65);
+        let persona_content =
+            format!("---\nname: \"{long_name}\"\ndisplay_name: T\ndescription: T.\n---\n");
+        std::fs::write(dir.join("agents/t.persona.md"), persona_content).unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors(), "65-char name should be an error");
+        let msg = format!("{report}");
+        assert!(
+            msg.contains("exceeds") && msg.contains("64"),
+            "error should mention 64-char limit, got: {msg}"
+        );
+    }
+
+    // ── Advisory: respond_to type validation ─────────────────────────────────
+
+    /// respond_to with wrong types in defaults → caught by typed parser.
+    /// The manifest's BehavioralDefaults uses typed RespondTo, so serde_json
+    /// rejects wrong types during load_pack(). This surfaces as a load error.
+    #[test]
+    fn validate_respond_to_bad_types() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        // mentions should be bool, not string — serde catches this at parse time.
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.bad-rt",
+                "name": "Bad RT Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "defaults": {
+                    "respond_to": {
+                        "mentions": "yes",
+                        "keywords": ["security"],
+                        "all_messages": false
+                    }
+                }
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(report.has_errors(), "bad respond_to types should be errors");
+        let msg = format!("{report}");
+        // Serde catches the type mismatch during manifest parsing.
+        assert!(
+            msg.contains("mentions") || msg.contains("invalid type"),
+            "should flag mentions type error, got: {msg}"
+        );
+    }
+
+    /// respond_to with correct types → no type errors.
+    #[test]
+    fn validate_respond_to_correct_types_ok() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.good-rt",
+                "name": "Good RT Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "defaults": {
+                    "respond_to": {
+                        "mentions": true,
+                        "keywords": ["security", "CVE"],
+                        "all_messages": false
+                    }
+                }
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(
+            !report.has_errors(),
+            "correct respond_to types should not cause errors, got: {report}"
+        );
+    }
+
+    /// respond_to with non-string items in keywords array → caught by serde.
+    #[test]
+    fn validate_respond_to_keywords_non_string_items() {
+        let tmp = tempfile::tempdir().unwrap();
+        let dir = tmp.path().to_path_buf();
+        std::fs::create_dir_all(dir.join(".plugin")).unwrap();
+        std::fs::create_dir_all(dir.join("agents")).unwrap();
+
+        std::fs::write(
+            dir.join(".plugin/plugin.json"),
+            r#"{
+                "id": "com.test.bad-kw",
+                "name": "Bad KW Pack",
+                "version": "0.1.0",
+                "personas": ["agents/t.persona.md"],
+                "defaults": {
+                    "respond_to": {
+                        "keywords": ["valid", 42, true]
+                    }
+                }
+            }"#,
+        )
+        .unwrap();
+        std::fs::write(
+            dir.join("agents/t.persona.md"),
+            "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+        )
+        .unwrap();
+
+        let report = validate_pack(&dir);
+        assert!(
+            report.has_errors(),
+            "non-string keyword items should be errors"
+        );
+        let msg = format!("{report}");
+        // Serde catches the type mismatch in the keywords array.
+        assert!(
+            msg.contains("keywords") || msg.contains("invalid type"),
+            "should flag keywords type error, got: {msg}"
+        );
+    }
+}
diff --git a/crates/sprout-persona/tests/integration.rs b/crates/sprout-persona/tests/integration.rs
new file mode 100644
index 0000000000..d24c13e513
--- /dev/null
+++ b/crates/sprout-persona/tests/integration.rs
@@ -0,0 +1,802 @@
+//! Integration tests for the sprout-persona crate.
+//!
+//! These exercise the full pipeline: build a pack on disk → load it →
+//! parse personas → resolve defaults → merge config → validate.
+//! Each test creates a temporary pack directory with realistic content.
+
+use std::fs;
+use std::path::Path;
+
+use sprout_persona::legacy;
+use sprout_persona::pack;
+use sprout_persona::persona;
+use sprout_persona::resolve;
+use sprout_persona::validate;
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Create a minimal valid pack in a temp directory.
+/// Returns the temp dir (holds the lifetime) and the pack root path.
+fn create_test_pack(dir: &Path) {
+    let plugin_dir = dir.join(".plugin");
+    fs::create_dir_all(&plugin_dir).unwrap();
+
+    let agents_dir = dir.join("agents");
+    fs::create_dir_all(&agents_dir).unwrap();
+
+    let skills_dir = dir.join("skills").join("code-review");
+    fs::create_dir_all(&skills_dir).unwrap();
+
+    // plugin.json
+    fs::write(
+        plugin_dir.join("plugin.json"),
+        r#"{
+  "id": "com.test.example-pack",
+  "name": "Example Pack",
+  "version": "1.0.0",
+  "description": "A test pack for integration tests.",
+  "personas": [
+    "agents/pip.persona.md",
+    "agents/lep.persona.md"
+  ],
+  "defaults": {
+    "model": "anthropic:claude-sonnet-4-20250514",
+    "temperature": 0.7,
+    "max_context_tokens": 128000,
+    "respond_to": {
+      "mentions": true,
+      "keywords": [],
+      "all_messages": false
+    },
+    "thread_replies": true,
+    "broadcast_replies": false
+  },
+  "pack_instructions": "instructions.md"
+}"#,
+    )
+    .unwrap();
+
+    // pip.persona.md — overrides model to Opus
+    fs::write(
+        agents_dir.join("pip.persona.md"),
+        r##"---
+name: "pip"
+display_name: "Pip 🌱"
+description: "Orchestration agent"
+model: "anthropic:claude-4-opus-20250514"
+subscribe:
+  - "#security-reviews"
+skills:
+  - "./skills/code-review/"
+---
+
+You are Pip, an orchestration agent. You coordinate the team.
+"##,
+    )
+    .unwrap();
+
+    // lep.persona.md — uses pack defaults
+    fs::write(
+        agents_dir.join("lep.persona.md"),
+        r##"---
+name: "lep"
+display_name: "Lep 🍀"
+description: "Security-focused code reviewer"
+respond_to:
+  mentions: true
+  keywords:
+    - "security"
+    - "vulnerability"
+    - "CVE"
+---
+
+You are Lep, a security-focused code reviewer on the team.
+"##,
+    )
+    .unwrap();
+
+    // SKILL.md
+    fs::write(
+        skills_dir.join("SKILL.md"),
+        r##"---
+name: "code-review"
+description: "Reviews code for quality and correctness"
+---
+
+# Code Review
+
+When asked to review code, follow these steps...
+"##,
+    )
+    .unwrap();
+
+    // instructions.md
+    fs::write(
+        dir.join("instructions.md"),
+        "# Team Instructions\n\nBe helpful and thorough.\n",
+    )
+    .unwrap();
+}
+
+// ── Full pipeline: load → parse → validate ───────────────────────────────────
+
+#[test]
+fn full_pipeline_load_and_validate() {
+    let dir = tempfile::tempdir().unwrap();
+    create_test_pack(dir.path());
+
+    // 1. Validate the pack — should be clean.
+    let report = validate::validate_pack(dir.path());
+    assert!(
+        !report.has_errors(),
+        "validation should pass on a valid pack, got: {report}"
+    );
+
+    // 2. Load the pack.
+    let loaded = pack::load_pack(dir.path()).unwrap();
+
+    // 3. Check manifest data.
+    assert_eq!(loaded.manifest.id, "com.test.example-pack");
+    assert_eq!(loaded.manifest.name, "Example Pack");
+    assert_eq!(loaded.manifest.version, "1.0.0");
+
+    // 4. Check personas loaded.
+    assert_eq!(loaded.personas.len(), 2);
+
+    let pip = loaded
+        .personas
+        .iter()
+        .find(|p| p.name == "pip")
+        .expect("pip persona should be loaded");
+    let lep = loaded
+        .personas
+        .iter()
+        .find(|p| p.name == "lep")
+        .expect("lep persona should be loaded");
+
+    // 5. Check pip's overrides.
+    assert_eq!(pip.display_name, "Pip 🌱");
+    assert_eq!(
+        pip.model.as_deref(),
+        Some("anthropic:claude-4-opus-20250514"),
+        "pip should override model to Opus"
+    );
+    assert!(
+        pip.prompt.contains("You are Pip"),
+        "pip should have system prompt"
+    );
+
+    // 6. Check lep inherits pack defaults.
+    assert_eq!(lep.display_name, "Lep 🍀");
+    assert_eq!(
+        lep.model.as_deref(),
+        Some("anthropic:claude-sonnet-4-20250514"),
+        "lep should inherit model from pack defaults"
+    );
+    assert!(lep.thread_replies, "lep should inherit thread_replies=true");
+    assert!(
+        !lep.broadcast_replies,
+        "lep should inherit broadcast_replies=false"
+    );
+
+    // 7. Check lep's triggers override.
+    let rt = lep.triggers.as_ref().expect("lep should have triggers");
+    assert!(rt.mentions, "lep triggers.mentions should be true");
+    assert!(
+        rt.keywords.contains(&"security".to_string()),
+        "lep should have 'security' keyword"
+    );
+
+    // 8. Pack instructions should be loaded.
+    assert!(
+        loaded.pack_instructions.is_some(),
+        "pack instructions should be loaded"
+    );
+    assert!(
+        loaded
+            .pack_instructions
+            .as_ref()
+            .unwrap()
+            .contains("Be helpful"),
+        "instructions content should match"
+    );
+}
+
+// ── Persona parser round-trip ────────────────────────────────────────────────
+
+#[test]
+fn persona_parse_round_trip() {
+    let md = r###"---
+name: "test-agent"
+display_name: "Test Agent"
+description: "A test agent for round-trip verification"
+model: "anthropic:claude-sonnet-4-20250514"
+temperature: 0.3
+subscribe:
+  - "#test-channel"
+respond_to:
+  mentions: true
+  keywords:
+    - "test"
+  all_messages: false
+thread_replies: true
+broadcast_replies: false
+---
+
+You are a test agent. Be precise and thorough.
+"###;
+
+    let config = persona::parse_persona_md(md).unwrap();
+
+    assert_eq!(config.name, "test-agent");
+    assert_eq!(config.display_name, "Test Agent");
+    assert_eq!(
+        config.description,
+        "A test agent for round-trip verification"
+    );
+    assert_eq!(
+        config.model.as_deref(),
+        Some("anthropic:claude-sonnet-4-20250514")
+    );
+    assert_eq!(config.temperature, Some(0.3));
+    assert_eq!(config.subscribe, Some(vec!["#test-channel".to_owned()]));
+    assert!(config.prompt.contains("Be precise and thorough"));
+
+    let rt = config.triggers.unwrap();
+    assert_eq!(rt.mentions, Some(true));
+    assert_eq!(rt.keywords, vec!["test"]);
+    assert_eq!(rt.all_messages, Some(false));
+}
+
+// ── Legacy JSON → PersonaConfig adapter ──────────────────────────────────────
+
+#[test]
+fn legacy_adapter_full_pipeline() {
+    let record = legacy::LegacyPersonaRecord {
+        id: "custom:security-bot".into(),
+        display_name: "Security Bot 🔒".into(),
+        avatar_url: Some("./avatars/security.png".into()),
+        system_prompt: "You are a security-focused assistant.\n\nAnalyze code for vulnerabilities."
+            .into(),
+        provider: Some("anthropic".into()),
+        model: Some("claude-sonnet-4-20250514".into()),
+        name_pool: vec!["sentinel".into(), "guardian".into()],
+        is_builtin: false,
+        is_active: true,
+        created_at: "2026-01-01T00:00:00Z".into(),
+        updated_at: "2026-04-01T00:00:00Z".into(),
+    };
+
+    // 1. Convert to PersonaConfig.
+    let config = legacy::legacy_to_persona_config(&record).unwrap();
+    assert_eq!(config.name, "security-bot");
+    assert_eq!(config.display_name, "Security Bot 🔒");
+    assert_eq!(
+        config.model.as_deref(),
+        Some("anthropic:claude-sonnet-4-20250514")
+    );
+    assert!(config.prompt.contains("security-focused assistant"));
+    // name_pool should NOT survive migration.
+    // (No field for it in PersonaConfig — this is correct.)
+
+    // 2. Generate .persona.md from the legacy record.
+    let (filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap();
+    assert_eq!(filename, "security-bot.persona.md");
+
+    // 3. Parse the generated .persona.md back.
+    let parsed = persona::parse_persona_md(&md_content).unwrap();
+    assert_eq!(parsed.name, "security-bot");
+    assert_eq!(parsed.display_name, "Security Bot 🔒");
+    assert_eq!(
+        parsed.model.as_deref(),
+        Some("anthropic:claude-sonnet-4-20250514")
+    );
+    assert!(parsed.prompt.contains("security-focused assistant"));
+    assert!(parsed.prompt.contains("Analyze code for vulnerabilities"));
+}
+
+// ── Legacy migration: batch JSON → pack directory ────────────────────────────
+
+#[test]
+fn legacy_batch_migration() {
+    let dir = tempfile::tempdir().unwrap();
+    let input_path = dir.path().join("personas.json");
+    let output_dir = dir.path().join("agents");
+
+    fs::write(
+        &input_path,
+        r#"[
+  {
+    "id": "custom:alpha",
+    "display_name": "Alpha",
+    "system_prompt": "You are Alpha.",
+    "is_builtin": false,
+    "is_active": true,
+    "created_at": "",
+    "updated_at": ""
+  },
+  {
+    "id": "builtin:solo",
+    "display_name": "Solo",
+    "system_prompt": "You are Solo.",
+    "is_builtin": true,
+    "is_active": true,
+    "created_at": "",
+    "updated_at": ""
+  },
+  {
+    "id": "custom:inactive",
+    "display_name": "Inactive",
+    "system_prompt": "You are Inactive.",
+    "is_builtin": false,
+    "is_active": false,
+    "created_at": "",
+    "updated_at": ""
+  }
+]"#,
+    )
+    .unwrap();
+
+    // Migrate with defaults (skip builtin, skip inactive).
+    let report = legacy::migrate_json_to_md(&input_path, &output_dir, false, false).unwrap();
+
+    assert_eq!(
+        report.migrated.len(),
+        1,
+        "only active non-builtin should migrate"
+    );
+    assert_eq!(report.migrated[0], "alpha.persona.md");
+    assert_eq!(report.skipped_builtin, 1);
+    assert_eq!(report.skipped_inactive, 1);
+
+    // Verify the migrated file parses.
+    let content = fs::read_to_string(output_dir.join("alpha.persona.md")).unwrap();
+    let parsed = persona::parse_persona_md(&content).unwrap();
+    assert_eq!(parsed.name, "alpha");
+    assert_eq!(parsed.display_name, "Alpha");
+    assert!(parsed.prompt.contains("You are Alpha"));
+}
+
+// ── Validation catches real errors ───────────────────────────────────────────
+
+#[test]
+fn validation_catches_missing_required_fields() {
+    let dir = tempfile::tempdir().unwrap();
+    let plugin_dir = dir.path().join(".plugin");
+    let agents_dir = dir.path().join("agents");
+    fs::create_dir_all(&plugin_dir).unwrap();
+    fs::create_dir_all(&agents_dir).unwrap();
+
+    // plugin.json with a persona reference.
+    fs::write(
+        plugin_dir.join("plugin.json"),
+        r#"{
+  "id": "com.test.bad-pack",
+  "name": "Bad Pack",
+  "version": "1.0.0",
+  "personas": ["agents/bad.persona.md"]
+}"#,
+    )
+    .unwrap();
+
+    // Persona missing required fields.
+    fs::write(
+        agents_dir.join("bad.persona.md"),
+        "---\nname: \"bad\"\n---\nNo display_name or description.\n",
+    )
+    .unwrap();
+
+    let report = validate::validate_pack(dir.path());
+    // load_pack fails on the first missing required field — the pack is
+    // structurally invalid and a single error is emitted.
+    assert!(report.has_errors(), "should flag missing required fields");
+}
+
+#[test]
+fn validation_catches_unknown_behavioral_keys() {
+    let dir = tempfile::tempdir().unwrap();
+    let plugin_dir = dir.path().join(".plugin");
+    let agents_dir = dir.path().join("agents");
+    fs::create_dir_all(&plugin_dir).unwrap();
+    fs::create_dir_all(&agents_dir).unwrap();
+
+    // plugin.json with a typo in defaults.
+    fs::write(
+        plugin_dir.join("plugin.json"),
+        r#"{
+  "id": "com.test.typo-pack",
+  "name": "Typo Pack",
+  "version": "1.0.0",
+  "personas": ["agents/t.persona.md"],
+  "defaults": {
+    "temprature": 0.5,
+    "model": "test"
+  }
+}"#,
+    )
+    .unwrap();
+    fs::write(
+        agents_dir.join("t.persona.md"),
+        "---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
+    )
+    .unwrap();
+
+    let report = validate::validate_pack(dir.path());
+    // Unknown manifest keys are advisory warnings, not hard errors.
+    assert!(
+        !report.has_errors(),
+        "unknown manifest keys should not be errors"
+    );
+    assert!(report.has_warnings(), "should catch typo in defaults");
+    let warn_str = format!("{report}");
+    assert!(
+        warn_str.contains("temprature"),
+        "should mention the typo: {warn_str}"
+    );
+}
+
+// ── model string splitting ───────────────────────────────────────────────────
+
+#[test]
+fn model_split_cases() {
+    // provider:model
+    let (provider, model) = persona::split_model("anthropic:claude-sonnet-4-20250514");
+    assert_eq!(provider, Some("anthropic"));
+    assert_eq!(model, "claude-sonnet-4-20250514");
+
+    // no colon — entire string is model
+    let (provider, model) = persona::split_model("gpt-4o");
+    assert_eq!(provider, None);
+    assert_eq!(model, "gpt-4o");
+
+    // multiple colons — split on first only
+    let (provider, model) = persona::split_model("custom:my:model:v2");
+    assert_eq!(provider, Some("custom"));
+    assert_eq!(model, "my:model:v2");
+}
+
+// ── Defaults merge: persona overrides pack defaults ──────────────────────────
+
+#[test]
+fn defaults_merge_persona_overrides() {
+    let dir = tempfile::tempdir().unwrap();
+    create_test_pack(dir.path());
+
+    let loaded = pack::load_pack(dir.path()).unwrap();
+
+    let pip = loaded.personas.iter().find(|p| p.name == "pip").unwrap();
+    let lep = loaded.personas.iter().find(|p| p.name == "lep").unwrap();
+
+    // pip overrides model; lep inherits.
+    assert_eq!(
+        pip.model.as_deref(),
+        Some("anthropic:claude-4-opus-20250514")
+    );
+    assert_eq!(
+        lep.model.as_deref(),
+        Some("anthropic:claude-sonnet-4-20250514")
+    );
+
+    // Both should inherit temperature from defaults (0.7).
+    assert_eq!(pip.temperature, Some(0.7));
+    assert_eq!(lep.temperature, Some(0.7));
+
+    // Both should inherit max_context_tokens from defaults (128000).
+    assert_eq!(pip.max_context_tokens, Some(128000));
+    assert_eq!(lep.max_context_tokens, Some(128000));
+}
+
+// ── Resolve pipeline: full end-to-end ────────────────────────────────────────
+
+/// Build a pack on disk → resolve → verify all fields on each persona.
+#[test]
+fn resolve_full_pipeline() {
+    let dir = tempfile::tempdir().unwrap();
+    create_test_pack(dir.path());
+
+    let resolved = resolve::resolve_pack(dir.path()).unwrap();
+
+    assert_eq!(resolved.id, "com.test.example-pack");
+    assert_eq!(resolved.name, "Example Pack");
+    assert_eq!(resolved.version, "1.0.0");
+    assert_eq!(resolved.personas.len(), 2);
+
+    let pip = resolved
+        .personas
+        .iter()
+        .find(|p| p.name == "pip")
+        .expect("pip should be resolved");
+    let lep = resolved
+        .personas
+        .iter()
+        .find(|p| p.name == "lep")
+        .expect("lep should be resolved");
+
+    // Identity
+    assert_eq!(pip.display_name, "Pip 🌱");
+    assert_eq!(pip.description, "Orchestration agent");
+    assert_eq!(pip.version, "1.0.0"); // defaults to pack version
+
+    // Model split: "anthropic:claude-4-opus-20250514" → provider + model
+    assert_eq!(pip.provider.as_deref(), Some("anthropic"));
+    assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));
+
+    // Lep inherits pack default model
+    assert_eq!(lep.provider.as_deref(), Some("anthropic"));
+    assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));
+
+    // System prompt composed: persona body + pack instructions
+    assert!(
+        pip.system_prompt.contains("You are Pip"),
+        "pip prompt should contain persona body"
+    );
+    assert!(
+        pip.system_prompt.contains("Be helpful"),
+        "pip prompt should contain pack instructions"
+    );
+    assert!(
+        pip.system_prompt.contains("Team Instructions"),
+        "pip prompt should contain instructions header"
+    );
+
+    // Temperature inherited from defaults
+    assert_eq!(pip.temperature, Some(0.7));
+    assert_eq!(lep.temperature, Some(0.7));
+
+    // Subscribe
+    assert_eq!(pip.subscribe, vec!["#security-reviews"]);
+
+    // Triggers: lep has explicit respond_to
+    assert!(lep.triggers.mentions);
+    assert!(lep.triggers.keywords.contains(&"security".to_string()));
+    assert!(lep.triggers.keywords.contains(&"vulnerability".to_string()));
+    assert!(!lep.triggers.all_messages);
+
+    // Goose env vars projected from model
+    let pip_env: std::collections::HashMap<_, _> = pip.goose_env_vars.iter().cloned().collect();
+    assert_eq!(
+        pip_env.get("GOOSE_PROVIDER").map(|s| s.as_str()),
+        Some("anthropic")
+    );
+    assert_eq!(
+        pip_env.get("GOOSE_MODEL").map(|s| s.as_str()),
+        Some("claude-4-opus-20250514")
+    );
+    assert_eq!(
+        pip_env.get("GOOSE_TEMPERATURE").map(|s| s.as_str()),
+        Some("0.7")
+    );
+}
+
+/// Pack with 3 personas, each with different configs.
+#[test]
+fn resolve_multi_persona_pack() {
+    let dir = tempfile::tempdir().unwrap();
+    let root = dir.path();
+
+    fs::create_dir_all(root.join(".plugin")).unwrap();
+    fs::create_dir_all(root.join("agents")).unwrap();
+
+    fs::write(
+        root.join(".plugin/plugin.json"),
+        r#"{
+  "id": "com.test.multi",
+  "name": "Multi Pack",
+  "version": "2.0.0",
+  "personas": [
+    "agents/alpha.persona.md",
+    "agents/beta.persona.md",
+    "agents/gamma.persona.md"
+  ],
+  "defaults": {
+    "model": "openai:gpt-4o",
+    "temperature": 0.5,
+    "thread_replies": true,
+    "broadcast_replies": false
+  }
+}"#,
+    )
+    .unwrap();
+
+    // Alpha: overrides model and temperature
+    fs::write(
+        root.join("agents/alpha.persona.md"),
+        "---\nname: alpha\ndisplay_name: Alpha\ndescription: The first.\nmodel: \"anthropic:claude-sonnet-4-20250514\"\ntemperature: 0.9\n---\nYou are Alpha.\n",
+    )
+    .unwrap();
+
+    // Beta: uses all defaults
+    fs::write(
+        root.join("agents/beta.persona.md"),
+        "---\nname: beta\ndisplay_name: Beta\ndescription: The second.\n---\nYou are Beta.\n",
+    )
+    .unwrap();
+
+    // Gamma: overrides subscribe and thread_replies
+    fs::write(
+        root.join("agents/gamma.persona.md"),
+        "---\nname: gamma\ndisplay_name: Gamma\ndescription: The third.\nsubscribe:\n  - \"#ops\"\nthread_replies: false\n---\nYou are Gamma.\n",
+    )
+    .unwrap();
+
+    let resolved = resolve::resolve_pack(root).unwrap();
+    assert_eq!(resolved.personas.len(), 3);
+
+    let alpha = resolved
+        .personas
+        .iter()
+        .find(|p| p.name == "alpha")
+        .unwrap();
+    let beta = resolved.personas.iter().find(|p| p.name == "beta").unwrap();
+    let gamma = resolved
+        .personas
+        .iter()
+        .find(|p| p.name == "gamma")
+        .unwrap();
+
+    // Alpha overrides model and temperature
+    assert_eq!(alpha.provider.as_deref(), Some("anthropic"));
+    assert_eq!(alpha.model.as_deref(), Some("claude-sonnet-4-20250514"));
+    assert_eq!(alpha.temperature, Some(0.9));
+
+    // Beta inherits all defaults
+    assert_eq!(beta.provider.as_deref(), Some("openai"));
+    assert_eq!(beta.model.as_deref(), Some("gpt-4o"));
+    assert_eq!(beta.temperature, Some(0.5));
+    assert!(beta.thread_replies);
+    assert!(!beta.broadcast_replies);
+
+    // Gamma overrides subscribe and thread_replies
+    assert_eq!(gamma.subscribe, vec!["#ops"]);
+    assert!(!gamma.thread_replies);
+
+    // All share pack version
+    assert_eq!(alpha.version, "2.0.0");
+    assert_eq!(beta.version, "2.0.0");
+    assert_eq!(gamma.version, "2.0.0");
+}
+
+/// resolve_persona_by_name finds the correct persona.
+#[test]
+fn resolve_persona_by_name_found() {
+    let dir = tempfile::tempdir().unwrap();
+    create_test_pack(dir.path());
+
+    let pip = resolve::resolve_persona_by_name(dir.path(), "pip").unwrap();
+    assert_eq!(pip.name, "pip");
+    assert_eq!(pip.display_name, "Pip 🌱");
+}
+
+/// resolve_persona_by_name returns error for nonexistent name.
+#[test]
+fn resolve_persona_by_name_not_found() {
+    let dir = tempfile::tempdir().unwrap();
+    create_test_pack(dir.path());
+
+    let err = resolve::resolve_persona_by_name(dir.path(), "nonexistent").unwrap_err();
+    assert!(
+        format!("{err}").contains("not found")
+            || matches!(err, pack::PackError::PersonaNotFound(_)),
+        "expected PersonaNotFound, got: {err}"
+    );
+}
+
+/// Legacy JSON → PersonaConfig → .persona.md → resolve round-trip.
+#[test]
+fn resolve_legacy_migration_roundtrip() {
+    let dir = tempfile::tempdir().unwrap();
+    let root = dir.path();
+
+    // 1. Start with a legacy JSON record
+    let record = legacy::LegacyPersonaRecord {
+        id: "custom:security-bot".into(),
+        display_name: "Security Bot 🔒".into(),
+        avatar_url: Some("./avatars/security.png".into()),
+        system_prompt: "You are a security-focused assistant.".into(),
+        provider: Some("anthropic".into()),
+        model: Some("claude-sonnet-4-20250514".into()),
+        name_pool: vec![],
+        is_builtin: false,
+        is_active: true,
+        created_at: String::new(),
+        updated_at: String::new(),
+    };
+
+    // 2. Convert to .persona.md
+    let (_filename, md_content) = legacy::legacy_to_persona_md(&record).unwrap();
+
+    // 3. Build a pack directory with the migrated persona
+    fs::create_dir_all(root.join(".plugin")).unwrap();
+    fs::create_dir_all(root.join("agents")).unwrap();
+
+    fs::write(
+        root.join(".plugin/plugin.json"),
+        r#"{"id":"com.test.migrated","name":"Migrated","version":"1.0.0","personas":["agents/security-bot.persona.md"]}"#,
+    )
+    .unwrap();
+    fs::write(root.join("agents/security-bot.persona.md"), &md_content).unwrap();
+
+    // 4. Resolve the pack
+    let resolved = resolve::resolve_pack(root).unwrap();
+    assert_eq!(resolved.personas.len(), 1);
+
+    let persona = &resolved.personas[0];
+    assert_eq!(persona.name, "security-bot");
+    assert_eq!(persona.display_name, "Security Bot 🔒");
+    assert_eq!(persona.provider.as_deref(), Some("anthropic"));
+    assert_eq!(persona.model.as_deref(), Some("claude-sonnet-4-20250514"));
+    assert!(persona.system_prompt.contains("security-focused assistant"));
+}
+
+// ── Validation: zero-persona and duplicate-name in full pipeline ─────────────
+
+/// Validation catches zero-persona packs in the full pipeline.
+#[test]
+fn validate_zero_personas_in_pipeline() {
+    let dir = tempfile::tempdir().unwrap();
+    let root = dir.path();
+
+    fs::create_dir_all(root.join(".plugin")).unwrap();
+    fs::write(
+        root.join(".plugin/plugin.json"),
+        r#"{"id":"com.test.empty","name":"Empty","version":"1.0.0","personas":[]}"#,
+    )
+    .unwrap();
+
+    let report = validate::validate_pack(root);
+    assert!(report.has_errors());
+    let msg = format!("{report}");
+    assert!(msg.contains("zero personas"), "got: {msg}");
+}
+
+/// Validation catches duplicate persona names in the full pipeline.
+#[test]
+fn validate_duplicate_names_in_pipeline() {
+    let dir = tempfile::tempdir().unwrap();
+    let root = dir.path();
+
+    fs::create_dir_all(root.join(".plugin")).unwrap();
+    fs::create_dir_all(root.join("agents")).unwrap();
+
+    fs::write(
+        root.join(".plugin/plugin.json"),
+        r#"{"id":"com.test.dupes","name":"Dupes","version":"1.0.0","personas":["agents/a.persona.md","agents/b.persona.md"]}"#,
+    )
+    .unwrap();
+    fs::write(
+        root.join("agents/a.persona.md"),
+        "---\nname: same\ndisplay_name: A\ndescription: First.\n---\n",
+    )
+    .unwrap();
+    fs::write(
+        root.join("agents/b.persona.md"),
+        "---\nname: same\ndisplay_name: B\ndescription: Second.\n---\n",
+    )
+    .unwrap();
+
+    let report = validate::validate_pack(root);
+    assert!(report.has_errors());
+    let msg = format!("{report}");
+    assert!(msg.contains("duplicate persona name"), "got: {msg}");
+}
+
+/// Operator config fields are rejected in persona frontmatter.
+/// This documents the security boundary: pack authors define behavior,
+/// operators define limits.
+#[test]
+fn operator_config_fields_rejected_in_frontmatter() {
+    for field in [
+        "idle_timeout",
+        "max_turn_duration",
+        "agents",
+        "heartbeat_interval",
+        "permission_mode",
+    ] {
+        let src =
+            format!("---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n{field}: 300\n---\n");
+        assert!(
+            persona::parse_persona_md(&src).is_err(),
+            "{field} should be rejected by deny_unknown_fields"
+        );
+    }
+}
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index ae836764d5..6712013015 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -4340,6 +4340,19 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.13.1",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
 [[package]]
 name = "serialize-to-javascript"
 version = "0.1.2"
@@ -4536,6 +4549,7 @@ dependencies = [
  "serde_json",
  "sha2 0.11.0",
  "sprout-core",
+ "sprout-persona",
  "tauri",
  "tauri-build",
  "tauri-plugin-dialog",
@@ -4566,6 +4580,16 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "sprout-persona"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "stable_deref_trait"
 version = "1.2.1"
@@ -5702,6 +5726,12 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
 [[package]]
 name = "untrusted"
 version = "0.9.0"
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index d961a16d53..ecac2b31b4 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -343,24 +343,17 @@ pub async fn create_managed_agent(
         // For pack-backed personas, resolve the installed pack path and the
         // persona's internal name (slug). ACP's resolve_persona_by_name()
         // matches on this internal name, NOT display_name.
-        let pack_metadata: Option<(std::path::PathBuf, String)> = requested_persona_id
-            .as_deref()
-            .and_then(|pid| {
+        let pack_metadata: Option<(std::path::PathBuf, String)> =
+            requested_persona_id.as_deref().and_then(|pid| {
                 let personas = load_personas(&app).ok()?;
                 let persona = personas.iter().find(|p| p.id == pid)?;
                 let pack_id = persona.source_pack.as_deref()?;
+                let slug = persona.source_pack_persona_slug.as_deref()?;
                 let base = managed_agents_base_dir(&app).ok()?;
                 let pack_path = base.join("packs").join(pack_id);
-                // Resolve the pack to find the internal name for this persona.
-                // Match by display_name since that's what PersonaRecord stores.
-                // TODO: fragile if two personas share display_name. Long-term fix:
-                // store internal `name` on PersonaRecord during import (requires
-                // adding a field to types.rs).
-                let resolved = sprout_persona::resolve::resolve_pack(&pack_path).ok()?;
-                let resolved_persona = resolved.personas.iter().find(|rp| {
-                    rp.display_name == persona.display_name
-                })?;
-                Some((pack_path, resolved_persona.name.clone()))
+                // Use the validated slug stored during import — no need to
+                // re-resolve the pack. The slug is [a-zA-Z0-9_-]+ by construction.
+                Some((pack_path, slug.to_owned()))
             });
 
         let record = crate::managed_agents::ManagedAgentRecord {
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index 443ec8b990..a6ac129810 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -7,8 +7,7 @@ use crate::{
     managed_agents::{
         encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
         load_personas, load_teams, parse_json_persona, parse_png_persona, parse_zip_personas,
-        save_managed_agents, save_personas,
-        uninstall_persona_pack as do_uninstall_persona_pack,
+        save_managed_agents, save_personas, uninstall_persona_pack as do_uninstall_persona_pack,
         validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
         PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
     },
@@ -77,6 +76,7 @@ pub fn create_persona(
         is_builtin: false,
         is_active: true,
         source_pack: None,
+        source_pack_persona_slug: None,
         created_at: now.clone(),
         updated_at: now,
     };
diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs
index fb95dff422..a9563da222 100644
--- a/desktop/src-tauri/src/managed_agents/personas.rs
+++ b/desktop/src-tauri/src/managed_agents/personas.rs
@@ -594,9 +594,7 @@ fn validate_pack_id(id: &str) -> Result<(), String> {
 /// Copy a directory tree, skipping symlinks (Lep F2: zip-slip defense).
 fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
     fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?;
-    for entry in
-        fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))?
-    {
+    for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? {
         let entry = entry.map_err(|e| format!("dir entry error: {e}"))?;
         let ft = entry
             .file_type()
diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs
index 6010d38424..dd5dd8414a 100644
--- a/desktop/src-tauri/src/managed_agents/personas/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs
@@ -1,7 +1,6 @@
 use super::{
-    ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
-    validate_pack_id, validate_persona_activation_change, validate_persona_deletion,
-    BUILT_IN_PERSONAS,
+    ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, validate_pack_id,
+    validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS,
 };
 use crate::managed_agents::PersonaRecord;
 
diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs
index 6d9ac69e12..e5dd392732 100644
--- a/desktop/src-tauri/src/managed_agents/teams.rs
+++ b/desktop/src-tauri/src/managed_agents/teams.rs
@@ -233,6 +233,8 @@ mod tests {
             name_pool: Vec::new(),
             is_builtin: false,
             is_active: true,
+            source_pack: None,
+            source_pack_persona_slug: None,
             created_at: "2026-03-20T00:00:00Z".to_string(),
             updated_at: "2026-03-20T00:00:00Z".to_string(),
         }
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index 78dd969190..759288a83f 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -35,6 +35,15 @@ pub struct PersonaRecord {
     pub is_builtin: bool,
     #[serde(default = "default_record_active")]
     pub is_active: bool,
+    /// Pack ID if this persona was imported from a persona pack.
+    /// Pack personas are non-editable (system_prompt, model locked).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub source_pack: Option<String>,
+    /// Internal persona slug within the pack (e.g., "lep", "pip").
+    /// Used by ACP's `resolve_persona_by_name()` to find the right persona.
+    /// Validated: `[a-zA-Z0-9_-]+`, max 64 chars (safe for env vars and paths).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub source_pack_persona_slug: Option<String>,
     pub created_at: String,
     pub updated_at: String,
 }
@@ -87,6 +96,12 @@ pub struct ManagedAgentRecord {
     pub backend_agent_id: Option<String>,
     #[serde(default)]
     pub provider_binary_path: Option<String>,
+    /// Installed pack path (absolute). Set when agent was created from a pack persona.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub persona_pack_path: Option<PathBuf>,
+    /// Persona name within the pack.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub persona_name_in_pack: Option<String>,
     pub created_at: String,
     pub updated_at: String,
     pub last_started_at: Option<String>,
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 9be6e1db7f..b3745b46c3 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -420,6 +420,8 @@ export type AgentPersona = {
   namePool: string[];
   isBuiltIn: boolean;
   isActive: boolean;
+  /** Pack ID if this persona was imported from a persona pack. Pack personas are non-editable. */
+  sourcePack?: string | null;
   createdAt: string;
   updatedAt: string;
 };

From 5ce2afacd5df9429c85e8dd85e52caabb5ddbada Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:16:23 -0400
Subject: [PATCH 14/28] chore: update file size limits for persona pack
 integration

---
 desktop/scripts/check-file-sizes.mjs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index c7aec283d4..3a3bcb5902 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -30,7 +30,7 @@ const rules = [
 
 // Exceptions should stay rare and temporary. Prefer splitting files instead.
 const overrides = new Map([
-  ["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Scout, Reviewer) keep this file slightly above the default cap even with tests extracted
+  ["src-tauri/src/managed_agents/personas.rs", 800], // built-in persona system prompts + persona pack import/uninstall/list functions
   ["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
   ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
   ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
@@ -42,7 +42,7 @@ const overrides = new Map([
   ["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
   ["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
   ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
-  ["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
+  ["src-tauri/src/commands/agents.rs", 880], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring
   ["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
   ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
   ["src/features/agents/hooks.ts", 520], // agent query/mutation surface now includes built-in persona library activation

From 253baf2b57a6bbf06c2da86b68e0857f0de407f6 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:24:23 -0400
Subject: [PATCH 15/28] =?UTF-8?q?docs:=20clean=20up=20persona=20pack=20spe?=
 =?UTF-8?q?c=20=E2=80=94=20remove=20version=20history,=20rename=20to=20PER?=
 =?UTF-8?q?SONA=5FPACK=5FSPEC.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Remove V7 version artifacts (status, replaces, changes-from-V6)
- Remove 'What This Spec Corrects' internal iteration table
- Rename respond_to → triggers throughout (avoids ACP naming collision)
- Rename 'Build Requirements' → 'Planned Features'
- Remove BR-N references, use plain language
- No YAML frontmatter — clean markdown document
---
 ...NA_PACK_SPEC_V7.md => PERSONA_PACK_SPEC.md | 171 +++++++-----------
 1 file changed, 66 insertions(+), 105 deletions(-)
 rename PERSONA_PACK_SPEC_V7.md => PERSONA_PACK_SPEC.md (84%)

diff --git a/PERSONA_PACK_SPEC_V7.md b/PERSONA_PACK_SPEC.md
similarity index 84%
rename from PERSONA_PACK_SPEC_V7.md
rename to PERSONA_PACK_SPEC.md
index 28a4a65161..716a4e958c 100644
--- a/PERSONA_PACK_SPEC_V7.md
+++ b/PERSONA_PACK_SPEC.md
@@ -1,22 +1,4 @@
-# Persona Pack Specification V7
-
-> **Status**: Draft  
-> **Replaces**: PERSONA_PACK_SPEC_V6.md  
-> **Last Updated**: 2026-04-10  
-> **Changes from V6**:
-> - **Flattened namespace**: All behavioral config fields (`model`, `temperature`, `subscribe`,
->   `respond_to`, `max_context_tokens`, `thread_replies`, `broadcast_replies`) are now **top-level
->   frontmatter fields** in `.persona.md` — the `sprout:` wrapper block is gone.
-> - **`plugin.json` cleanup**: `sprout.defaults` becomes `defaults`; `sprout.personas` becomes
->   `personas`. The `sprout` wrapper object in `plugin.json` is eliminated. OPS-standard fields
->   remain at the top level as before.
-> - **Generic field names for cross-project portability**: No vendor prefix required to adopt this
->   persona format. Since the Open Plugin Spec has zero model configuration, there are no field
->   name collisions.
-> - All V6 corrections, architecture, precedence model logic, build requirements, and merge
->   semantics are preserved — only field references are updated.
-
----
+# Persona Pack Specification
 
 ## 1. Overview & Goals
 
@@ -33,23 +15,6 @@ server config, pack-level instructions, lifecycle hooks, and distribution metada
 2. **Composable** — skills and MCP servers shared across agents; per-agent overrides additive
 3. **OPS-compatible** — discoverable by any OPS-compatible tool
 4. **Harness-honest** — explicit about what goose does vs. what sprout-acp does
-5. **Build-honest** — features requiring new sprout-acp implementation are labeled as such
-
-### What This Spec Corrects (V1, V2, and V3 Errors)
-
-| V1/V2/V3 Assumption | Reality | Fix |
-|---------------------|---------|-----|
-| `--skill-path` CLI flag exists | Does not exist in goose | Skills copied to `.agents/skills/` by harness |
-| `--rules-dir` flag exists | Does not exist | Rules injected via user message prefix |
-| `--system-prompt-file` flag works in ACP mode | Does not exist in goose-acp | Use `agent.extend_system_prompt()` (BR-1) |
-| `.mdc` rules files supported | Goose does not read `.mdc` files | Concept eliminated |
-| `skills/` at pack root is discovered | Goose scans `.agents/skills/`, not `skills/` | Skills go to `.agents/skills/` |
-| Goose has a hook system | Zero hook support in goose codebase | Hooks are harness-only |
-| `[System]` block injection is implemented | Not implemented in goose-acp today | Marked as BR-1 |
-| Directory name is fallback when `name:` absent | **No fallback — skill silently skipped** | Both `name:` and `description:` required |
-| Per-persona env vars set before subprocess | `AcpClient::spawn` inherits parent env only | Marked as BR-6 |
-| SSE MCP transport works | goose-acp rejects SSE with an error | Use stdio or streamable_http |
-| `$AGENT_CWD` is an env var goose reads | It's `NewSessionRequest.cwd` in the ACP protocol | Reframed throughout |
 
 ---
 
@@ -89,7 +54,7 @@ ignore unknown fields.
     "model": "anthropic:claude-sonnet-4-20250514",
     "temperature": 0.7,
     "max_context_tokens": 128000,
-    "respond_to": {
+    "triggers": {
       "mentions": true,
       "keywords": [],
       "all_messages": false
@@ -115,7 +80,7 @@ subscribe:
 pip gets Opus; lep, thistle, and berry get Sonnet. Temperature 0.7 applies to all four because
 none of them override it.
 
-> **Note**: `subscribe` and `respond_to` in `defaults` are valid but unusual — most packs set
+> **Note**: `subscribe` and `triggers` in `defaults` are valid but unusual — most packs set
 > these per-persona since agents typically monitor different channels and respond to different
 > triggers.
 
@@ -181,12 +146,12 @@ A persona file is a markdown document with YAML frontmatter. The **YAML frontmat
 identity, skills, MCP servers, and behavioral config. The **markdown body** (everything after the
 closing `---`) is the agent's persona prompt text.
 
-> **Build Requirement**: Delivery of the persona prompt as a true goose system prompt requires
-> sprout-acp to call `agent.extend_system_prompt()` (or `agent.override_system_prompt()`) after
+> **Note**: Delivery of the persona prompt as a true goose system prompt requires sprout-acp to
+> call `agent.extend_system_prompt()` (or `agent.override_system_prompt()`) after
 > `create_agent_for_session()` in `on_new_session()`. These methods exist in
 > `crates/goose/src/agents/agent.rs` but are **not currently called from the ACP path**. Until
 > this is implemented, sprout-acp prepends the persona prompt as a `[System]` prefix in the user
-> message text (see Section 11). See Section 15 for the full build requirements list.
+> message text (see Section 11). True system prompt injection is planned — see Section 15.
 
 ### Full Schema
 
@@ -219,7 +184,7 @@ mcp_servers:
 subscribe:
   - "#security-reviews"
   - "#code-reviews"
-respond_to:
+triggers:
   mentions: true
   keywords: ["security", "vulnerability", "CVE"]
 model: "anthropic:claude-sonnet-4-20250514"
@@ -250,7 +215,7 @@ You are Lep, a security-focused code reviewer on the Meadow team.
 | `skills` | string[] | ❌ | Pack-relative paths to skill directories for this agent only. |
 | `mcp_servers` | object[] | ❌ | Per-persona MCP servers. Merged with pack-level `.mcp.json`. |
 | `subscribe` | string[] | ❌ | Channels to monitor. See Section 9. |
-| `respond_to` | object | ❌ | Controls which messages trigger a response. See Section 9. |
+| `triggers` | object | ❌ | Controls which messages activate a response. See Section 9. |
 | `model` | string | ❌ | Model to use. See Section 9. |
 | `temperature` | float | ❌ | Sampling temperature. See Section 9. |
 | `max_context_tokens` | int | ❌ | Context window limit. See Section 9. |
@@ -261,8 +226,8 @@ You are Lep, a security-focused code reviewer on the Meadow team.
 ### Markdown Body (Persona Prompt)
 
 Everything after the closing `---` is the persona prompt text. Pack-level `instructions.md` is
-appended after it. Embed the prompt directly — do not reference external files, `--system-prompt-file`
-(does not exist in goose-acp), or `.mdc` rule files (goose does not read them).
+appended after it. Embed the prompt directly — do not reference external files or `.mdc` rule
+files (goose does not read them).
 
 ---
 
@@ -369,7 +334,7 @@ description: "Reviews code for security vulnerabilities using OWASP Top 10 and s
 ```
 
 Both `name:` and `description:` are **required**. A skill missing either field is silently skipped
-by goose. `sprout pack validate` (BR-2) must flag missing fields as errors.
+by goose. `sprout pack validate` (planned) must flag missing fields as errors.
 
 ---
 
@@ -549,7 +514,7 @@ which uses Opus.
     "model": "anthropic:claude-sonnet-4-20250514",
     "temperature": 0.7,
     "max_context_tokens": 128000,
-    "respond_to": {
+    "triggers": {
       "mentions": true,
       "keywords": [],
       "all_messages": false
@@ -561,7 +526,7 @@ which uses Opus.
 }
 ```
 
-> **Note**: `subscribe` and `respond_to` in `defaults` are valid but unusual — most packs set
+> **Note**: `subscribe` and `triggers` in `defaults` are valid but unusual — most packs set
 > these per-persona since agents typically monitor different channels and respond to different
 > triggers.
 
@@ -603,9 +568,9 @@ sprout-acp MUST NOT override them with pack/persona values. sprout-acp only inje
 fields that are NOT already set in the parent environment. This ensures operators can always
 override pack configuration by setting env vars on the sprout-acp process.
 
-**Implementation**: when constructing the child process environment (BR-6), sprout-acp checks
-`std::env::var(key)` for each env var. If the parent already has it set, skip injection. If not,
-inject the resolved pack/persona value.
+**Implementation**: when constructing the child process environment (planned — see Section 15),
+sprout-acp checks `std::env::var(key)` for each env var. If the parent already has it set, skip
+injection. If not, inject the resolved pack/persona value.
 
 ### Empty and `null` Semantics
 
@@ -622,8 +587,8 @@ behavioral config frontmatter fields:
 - **`subscribe: []`** — an empty array is NOT treated as absent. It means "subscribe to nothing."
   This is an intentional override that prevents pack defaults from applying.
 
-- **`respond_to: {}`** — an empty object is NOT treated as absent. It means "use default sub-field
-  values." This overrides the pack default `respond_to` object entirely, and each sub-field falls
+- **`triggers: {}`** — an empty object is NOT treated as absent. It means "use default sub-field
+  values." This overrides the pack default `triggers` object entirely, and each sub-field falls
   through to its built-in default.
 
 > **Rule of thumb**: `null` = absent (fall through). Empty containers (`[]`, `{}`) = present
@@ -635,9 +600,9 @@ Field merging is **shallow replacement** — there is no deep merge. The rules a
 
 - **Simple fields** (`model`, `temperature`, `max_context_tokens`, `thread_replies`,
   `broadcast_replies`): the first defined value in the precedence chain wins entirely.
-- **Object fields** (`respond_to`): if the persona sets `respond_to`, the entire object replaces
-  the pack default. Individual sub-keys are not merged. If the persona does not set `respond_to`,
-  the pack default `respond_to` object is used as-is.
+- **Object fields** (`triggers`): if the persona sets `triggers`, the entire object replaces
+  the pack default. Individual sub-keys are not merged. If the persona does not set `triggers`,
+  the pack default `triggers` object is used as-is.
 - **Array fields** (`subscribe`): if the persona sets `subscribe`, the entire array replaces the
   pack default. There is no union or append behavior. If the persona does not set `subscribe`, the
   pack default `subscribe` array is used as-is.
@@ -646,12 +611,12 @@ Field merging is **shallow replacement** — there is no deep merge. The rules a
 
 Pack default (`defaults` in `plugin.json`):
 ```json
-"respond_to": { "mentions": true, "keywords": ["security"], "all_messages": false }
+"triggers": { "mentions": true, "keywords": ["security"], "all_messages": false }
 ```
 
 Persona override (frontmatter):
 ```yaml
-respond_to:
+triggers:
   mentions: true
   all_messages: true
 ```
@@ -661,7 +626,7 @@ Effective result for that persona:
 { "mentions": true, "all_messages": true }
 ```
 
-Note: `keywords` is **gone** — the persona's `respond_to` replaced the entire object. There is no
+Note: `keywords` is **gone** — the persona's `triggers` replaced the entire object. There is no
 implicit inheritance of sub-keys.
 
 **Example — array replacement**:
@@ -684,12 +649,12 @@ Effective result: `["#security-reviews", "#code-reviews"]` — `#general` is not
 
 Pack default (`defaults` in `plugin.json`):
 ```json
-"respond_to": { "mentions": true, "keywords": ["security"], "all_messages": false }
+"triggers": { "mentions": true, "keywords": ["security"], "all_messages": false }
 ```
 
 Persona override (frontmatter):
 ```yaml
-respond_to: {}
+triggers: {}
 ```
 
 Effective result for that persona:
@@ -697,7 +662,7 @@ Effective result for that persona:
 { "mentions": true, "keywords": [], "all_messages": false }
 ```
 
-Note: `respond_to: {}` is NOT absent — it overrides the pack default entirely. Each sub-field
+Note: `triggers: {}` is NOT absent — it overrides the pack default entirely. Each sub-field
 falls through to its **built-in default** (not the pack default). `mentions` defaults to `true`,
 `keywords` to `[]`, `all_messages` to `false`.
 
@@ -710,10 +675,10 @@ apply to both.
 | Field | Type | Built-in Default | Valid Range / Values | Description |
 |-------|------|-----------------|----------------------|-------------|
 | `subscribe` | string[] | `[]` | Any channel name strings | Channels to monitor. `#` prefix stripped before relay calls. |
-| `respond_to` | object | see sub-fields | — | Controls which messages trigger a response. Replaced as a whole unit on override. |
-| `respond_to.mentions` | bool | `true` | `true` / `false` | Respond when @mentioned. |
-| `respond_to.keywords` | string[] | `[]` | Any strings | Respond when message contains any keyword (case-insensitive). |
-| `respond_to.all_messages` | bool | `false` | `true` / `false` | Respond to every message in subscribed channels. |
+| `triggers` | object | see sub-fields | — | Controls which messages activate a response. Replaced as a whole unit on override. |
+| `triggers.mentions` | bool | `true` | `true` / `false` | Respond when @mentioned. |
+| `triggers.keywords` | string[] | `[]` | Any strings | Respond when message contains any keyword (case-insensitive). |
+| `triggers.all_messages` | bool | `false` | `true` / `false` | Respond to every message in subscribed channels. |
 | `model` | string | none (goose uses operator default) | `"provider:model-id"` format | Model to use. Split on first `:` → `GOOSE_PROVIDER` + `GOOSE_MODEL`. |
 | `temperature` | float | `0.7` | Provider-dependent (typically 0.0–2.0). sprout-acp passes through without range validation; `sprout pack validate` checks type only (must be a number), not range. | Sampling temperature → `GOOSE_TEMPERATURE`. |
 | `max_context_tokens` | int | none (provider default) | Positive integer | Context window limit → `GOOSE_CONTEXT_LIMIT`. |
@@ -721,9 +686,9 @@ apply to both.
 | `broadcast_replies` | bool | `false` | `true` / `false` | Also surface thread replies to the main channel. |
 
 **Unknown keys** in `defaults` (in `plugin.json`) or in a persona's frontmatter behavioral config
-fields are **validation errors** in `sprout pack validate` (BR-2) — this catches typos like
-`temprature` at validate time. At deploy time, sprout-acp logs a `WARN` and ignores the unknown
-key, remaining fail-soft:
+fields are **validation errors** in `sprout pack validate` — this catches typos like `temprature`
+at validate time. At deploy time, sprout-acp logs a `WARN` and ignores the unknown key, remaining
+fail-soft:
 
 ```
 WARN: Unknown key "temprature" in defaults (plugin.json); ignoring
@@ -738,7 +703,7 @@ subscribe:
   - "#security-reviews"
   - "#code-reviews"
 
-respond_to:
+triggers:
   mentions: true
   keywords:
     - "security"
@@ -768,7 +733,7 @@ effective configuration per persona **before** injecting environment variables i
 process. The env vars set reflect the fully-resolved values — not the raw persona frontmatter.
 
 sprout-acp translates persona behavioral config fields to goose configuration via environment
-variables injected into the child process at spawn time (requires BR-6):
+variables injected into the child process at spawn time (planned — see Section 15):
 
 | Persona field | Env var(s) | Notes |
 |---|---|---|
@@ -781,8 +746,9 @@ If `model` is omitted from both the persona frontmatter and `defaults`, sprout-a
 
 > **Current limitation**: `AcpClient::spawn` (in `crates/sprout-acp/src/acp.rs`) inherits the
 > parent process environment — there are no `.env()` calls. In a multi-persona deployment, env
-> vars set on the parent affect **all** spawned agents. Per-persona model config requires BR-6.
-> Until then, per-persona model configuration is only safe with one sprout-acp process per persona.
+> vars set on the parent affect **all** spawned agents. Per-persona model config requires
+> per-subprocess env var injection (planned — see Section 15). Until then, per-persona model
+> configuration is only safe with one sprout-acp process per persona.
 >
 > **Alternative**: goose-acp implements `session/set_model` (`on_set_model()` in `server.rs`,
 > ACP unstable). This could set the model per-session after `session/new` without env var
@@ -878,7 +844,7 @@ How each pack component reaches the running agent:
 | Persona prompt | User message prefix | `[System]` block prepended to user message text by sprout-acp | ❌ No |
 | Pack instructions | User message prefix | Appended to `[System]` block in user message text | ❌ No |
 | Lifecycle hooks | Harness internal | sprout-acp fires shell commands directly | ❌ No |
-| Model/provider | Child process env vars | `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT` (requires BR-6) | ❌ No |
+| Model/provider | Child process env vars | `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT` (planned — see Section 15) | ❌ No |
 | Behavioral config | Harness internal | sprout-acp subscription + dispatch logic | ❌ No |
 | Pack defaults (`defaults`) | Harness internal | Resolved at deploy time by sprout-acp into per-persona effective config; never passed to goose directly | ❌ No |
 
@@ -910,11 +876,11 @@ Available skills: code-review, security-review, shared
 Load a skill with: load(source: "skill-name")
 ```
 
-### True System Prompt Injection — Build Requirement (BR-1)
+### True System Prompt Injection — Planned
 
 The `[System]` prefix re-sends the full persona prompt on every turn. True system prompt injection
 — calling `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`
-— fires once at session creation. See BR-1 in Section 15.
+— fires once at session creation. This is planned work; see Section 15.
 
 ### What Does NOT Work (Anti-Pattern Reference)
 
@@ -928,7 +894,7 @@ The `[System]` prefix re-sends the full persona prompt on every turn. True syste
 | Hooks in goose config | Goose has no hook system |
 | SSE transport in `.mcp.json` | goose-acp rejects SSE; use stdio or streamable_http |
 | SKILL.md without `name:` or `description:` | Skill silently skipped; no fallback |
-| Setting `GOOSE_MODEL` on parent process (multi-persona) | Affects all agents; use per-subprocess injection (BR-6) |
+| Setting `GOOSE_MODEL` on parent process (multi-persona) | Affects all agents; use per-subprocess injection (planned) |
 | Expecting `defaults` sub-key inheritance | No deep merge; object/array fields replaced entirely |
 
 ---
@@ -968,15 +934,15 @@ both with the same caution as any untrusted prompt content.
 
 ### From V6 (sprout-namespaced) Format
 
-Field mapping from V6 `.persona.md` to V7 `.persona.md`:
+Field mapping from V6 `.persona.md` to current `.persona.md`:
 
-| V6 location | V7 location |
+| V6 location | Current location |
 |---|---|
 | `sprout.model` | `model` (top-level frontmatter) |
 | `sprout.temperature` | `temperature` (top-level frontmatter) |
 | `sprout.max_context_tokens` | `max_context_tokens` (top-level frontmatter) |
 | `sprout.subscribe` | `subscribe` (top-level frontmatter) |
-| `sprout.respond_to` | `respond_to` (top-level frontmatter) |
+| `sprout.respond_to` | `triggers` (top-level frontmatter) |
 | `sprout.thread_replies` | `thread_replies` (top-level frontmatter) |
 | `sprout.broadcast_replies` | `broadcast_replies` (top-level frontmatter) |
 | `plugin.json` → `sprout.defaults` | `plugin.json` → `defaults` (top-level) |
@@ -994,7 +960,7 @@ sprout:
     - "#security-reviews"
 ```
 
-**V7 persona frontmatter** (after):
+**Current persona frontmatter** (after):
 ```yaml
 model: "anthropic:claude-sonnet-4-20250514"
 temperature: 0.3
@@ -1010,7 +976,7 @@ subscribe:
 }
 ```
 
-**V7 `plugin.json`** (after):
+**Current `plugin.json`** (after):
 ```json
 "personas": ["agents/pip.persona.md"],
 "defaults": { "model": "anthropic:claude-sonnet-4-20250514" }
@@ -1038,40 +1004,35 @@ Field mapping from flat JSON (`personas/lep.json`) to `.persona.md`:
 
 ### Backward Compatibility
 
-sprout-acp supports both V6 (namespaced `sprout:` block) and V7 (flat top-level fields) formats
-during the transition period. The V6 namespaced format is deprecated as of the version that ships
-this spec and will be removed in the next major version.
+sprout-acp supports both V6 (namespaced `sprout:` block) and the current (flat top-level fields)
+formats during the transition period. The V6 namespaced format is deprecated and will be removed
+in the next major version.
 
 ---
 
 ## 14. Open Questions / Future Work
 
-### Closed Questions
-
-1. ~~**`--system-prompt-file` in ACP mode**~~: **Closed.** The flag does not exist in goose-acp.
-   sprout-acp uses `agent.extend_system_prompt()` instead (see BR-1).
-
 ### Unresolved
 
-2. **`session/set_model` as env var alternative**: goose-acp implements `on_set_model()` (ACP
+1. **`session/set_model` as env var alternative**: goose-acp implements `on_set_model()` (ACP
    unstable feature). sprout-acp could call `session/set_model` after `session/new` to set the
    model per-session without env var injection. This avoids the `AcpClient::spawn` limitation for
    model (but not provider, temperature, or context limit). Deferred pending stability of the ACP
    unstable feature.
 
-3. **`CONTEXT_FILE_NAMES` env var**: Goose supports this env var to control which filenames are
+2. **`CONTEXT_FILE_NAMES` env var**: Goose supports this env var to control which filenames are
    scanned for hints. Should sprout-acp set this to include pack-specific filenames? Deferred
    pending use case.
 
-4. **Skill versioning**: Skills are identified by load key only. If two packs provide a skill with
+3. **Skill versioning**: Skills are identified by load key only. If two packs provide a skill with
    the same name, the no-overwrite rule means the first-installed wins silently. A versioned skill
    format (e.g., `code-review@1.2.0`) would resolve this.
 
-5. **Pack signing**: Phase 3 registry needs a signing scheme. Ed25519 keypairs tied to pack author
+4. **Pack signing**: Phase 3 registry needs a signing scheme. Ed25519 keypairs tied to pack author
    identity is the likely approach, but not yet designed.
 
-6. **Multi-pack conflicts**: What happens when two installed packs define agents that subscribe to
-   the same channel with overlapping `respond_to` rules? Need a conflict resolution policy.
+5. **Multi-pack conflicts**: What happens when two installed packs define agents that subscribe to
+   the same channel with overlapping `triggers` rules? Need a conflict resolution policy.
 
 ### Future Work
 
@@ -1079,19 +1040,19 @@ this spec and will be removed in the next major version.
 
 ---
 
-## 15. Build Requirements
+## 15. Planned Features
 
-Features required by this spec but not yet implemented. These are implementation tasks.
+Features required by this spec but not yet implemented.
 
 | ID | What | Where |
 |----|------|-------|
-| BR-1 | True system prompt injection: call `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`. Current `[System]` prefix re-sends persona prompt on every turn; true injection fires once at session creation. | `goose/src/agents/agent.rs` + `goose-acp/src/server.rs` `on_new_session()` |
-| BR-2 | `sprout pack validate` CLI: schema-validate `plugin.json` (including `defaults` — unknown keys are errors, type mismatches are errors); check `.persona.md` required identity fields; validate per-persona behavioral config fields against the canonical schema; verify all `skills:` and `hooks:` paths exist; error on `SKILL.md` missing `name:` or `description:`; warn when `name:` differs from directory name. | `sprout-cli` / `sprout-admin` |
-| BR-3 | Skill collision warning: emit `WARN` when a pack skill is skipped because a skill with the same load key already exists in `.agents/skills/`. | sprout-acp skill copy logic |
-| BR-4 | `$AGENT_CWD` resolution: determine `NewSessionRequest.cwd` from (1) `AGENT_CWD` env var, (2) `std::env::current_dir()`, (3) error and refuse to start. | sprout-acp startup / session init |
-| BR-5 | Skill parse failure warning: emit `WARN` when `parse_skill_content` returns `None` (missing `name:`, missing `description:`, or malformed frontmatter). Currently goose silently skips. Until fixed in goose, sprout-acp should pre-validate during skill copy. | `goose/src/agents/platform_extensions/summon.rs` `parse_skill_content()` |
-| BR-6 | Per-subprocess env var injection: extend `AcpClient::spawn` to accept `Vec<(String, String)>` injected via `Command::env()`. Required so `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, and `GOOSE_CONTEXT_LIMIT` can differ per persona without affecting all agents. sprout-acp must check `std::env::var(key)` before injecting — if the parent environment already has the key set, skip injection (operator env vars take precedence, level 1). | `sprout-acp/src/acp.rs` `AcpClient::spawn()` |
+| PF-1 | True system prompt injection: call `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`. Current `[System]` prefix re-sends persona prompt on every turn; true injection fires once at session creation. | `goose/src/agents/agent.rs` + `goose-acp/src/server.rs` `on_new_session()` |
+| PF-2 | `sprout pack validate` CLI: schema-validate `plugin.json` (including `defaults` — unknown keys are errors, type mismatches are errors); check `.persona.md` required identity fields; validate per-persona behavioral config fields against the canonical schema; verify all `skills:` and `hooks:` paths exist; error on `SKILL.md` missing `name:` or `description:`; warn when `name:` differs from directory name. | `sprout-cli` / `sprout-admin` |
+| PF-3 | Skill collision warning: emit `WARN` when a pack skill is skipped because a skill with the same load key already exists in `.agents/skills/`. | sprout-acp skill copy logic |
+| PF-4 | `$AGENT_CWD` resolution: determine `NewSessionRequest.cwd` from (1) `AGENT_CWD` env var, (2) `std::env::current_dir()`, (3) error and refuse to start. | sprout-acp startup / session init |
+| PF-5 | Skill parse failure warning: emit `WARN` when `parse_skill_content` returns `None` (missing `name:`, missing `description:`, or malformed frontmatter). Currently goose silently skips. Until fixed in goose, sprout-acp should pre-validate during skill copy. | `goose/src/agents/platform_extensions/summon.rs` `parse_skill_content()` |
+| PF-6 | Per-subprocess env var injection: extend `AcpClient::spawn` to accept `Vec<(String, String)>` injected via `Command::env()`. Required so `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, and `GOOSE_CONTEXT_LIMIT` can differ per persona without affecting all agents. sprout-acp must check `std::env::var(key)` before injecting — if the parent environment already has the key set, skip injection (operator env vars take precedence, level 1). | `sprout-acp/src/acp.rs` `AcpClient::spawn()` |
 
 ---
 
-*End of Persona Pack Specification V7*
+*End of Persona Pack Specification*

From ac70116ce2fed877ae0ef54d1d6765d88401f380 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:39:35 -0400
Subject: [PATCH 16/28] =?UTF-8?q?fix:=20address=20codex=20review=20?=
 =?UTF-8?q?=E2=80=94=20wire=20description,=20clarify=20deferred=20features?=
 =?UTF-8?q?=20in=20spec?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Wire pack description through PackManifestData to ResolvedPack
- Document persona version defaulting (no per-persona version field yet)
- Add implementation notes for deferred features: MCP interpolation, hooks, skills
- Clarify AGENT_CWD definition (operator env var → ACP protocol field)
- All 156 tests passing
---
 PERSONA_PACK_SPEC.md                 | 21 +++++++++++++++++----
 crates/sprout-persona/src/pack.rs    |  2 ++
 crates/sprout-persona/src/resolve.rs |  8 ++++++--
 3 files changed, 25 insertions(+), 6 deletions(-)

diff --git a/PERSONA_PACK_SPEC.md b/PERSONA_PACK_SPEC.md
index 716a4e958c..3f2792d7d8 100644
--- a/PERSONA_PACK_SPEC.md
+++ b/PERSONA_PACK_SPEC.md
@@ -233,6 +233,10 @@ files (goose does not read them).
 
 ## 5. Skills
 
+> **Implementation note**: Skill paths are stored as declared in persona frontmatter. Resolution
+> to `SKILL.md` `name:` fields and runtime copying to `$AGENT_CWD/.agents/skills/` is planned
+> for a future release.
+
 Skills are reusable instruction sets that agents load on demand. They are markdown files that teach
 the agent how to perform a specific task.
 
@@ -253,12 +257,14 @@ $AGENT_CWD/.agents/skills/<skill-name>/SKILL.md   ← sprout-acp uses this one
 ### `$AGENT_CWD` Definition
 
 Throughout this spec, **`$AGENT_CWD`** refers to the `cwd` field in the ACP `NewSessionRequest`
-— the working directory passed to goose-acp when creating a session. This is an ACP protocol
-field, not an environment variable that goose reads at runtime.
+— the working directory passed to goose-acp when creating a session. Goose does not read an
+`AGENT_CWD` environment variable at runtime; the value is delivered via the ACP protocol.
 
-sprout-acp determines what value to pass as `NewSessionRequest.cwd` in this order:
+However, operators can control this value by setting the `AGENT_CWD` environment variable on the
+sprout-acp process. sprout-acp determines what value to pass as `NewSessionRequest.cwd` in this
+order:
 
-1. The `AGENT_CWD` environment variable, if set.
+1. The `AGENT_CWD` environment variable on the sprout-acp process, if set.
 2. `std::env::current_dir()` as a fallback.
 3. If both fail, sprout-acp logs an error and **refuses to start**.
 
@@ -388,6 +394,10 @@ mcp_servers:
 
 ### Environment Variable Interpolation
 
+> **Implementation note**: MCP env var interpolation (`${VAR_NAME}` resolution) is planned but not
+> yet implemented. In the current release, `${VAR_NAME}` strings are passed through as literals
+> to goose, which resolves them via its own MCP server configuration handling.
+
 All `env` values are scanned for `${VAR_NAME}`. sprout-acp resolves from the process environment
 **before** passing to goose-acp. Unresolved variables cause a startup error:
 
@@ -422,6 +432,9 @@ contributors only).
 
 ## 8. Lifecycle Hooks
 
+> **Implementation note**: Hooks are parsed and validated at pack load time but not yet executed.
+> Hook execution is planned for a future release.
+
 Hooks are shell commands fired by sprout-acp at agent lifecycle points. **Goose has no hook system**
 — hooks are entirely a harness feature.
 
diff --git a/crates/sprout-persona/src/pack.rs b/crates/sprout-persona/src/pack.rs
index b1ee60b863..727e39e63f 100644
--- a/crates/sprout-persona/src/pack.rs
+++ b/crates/sprout-persona/src/pack.rs
@@ -105,6 +105,7 @@ pub struct PackManifestData {
     pub id: String,
     pub name: String,
     pub version: String,
+    pub description: Option<String>,
     /// Relative paths to .persona.md files.
     pub personas: Vec<String>,
     pub pack_instructions: Option<String>,
@@ -142,6 +143,7 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
         id: pm.id,
         name: pm.name,
         version: pm.version,
+        description: pm.description,
         personas: pm.personas,
         pack_instructions: pm.pack_instructions,
         mcp_config: pm.mcp_config,
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index 4f3cb59614..cc3a72d961 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -130,7 +130,7 @@ pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackErro
         name: loaded.manifest.name.clone(),
         version: loaded.manifest.version.clone(),
         // Pack-level description not yet wired through PackManifestData.
-        description: String::new(),
+        description: loaded.manifest.description.clone().unwrap_or_default(),
         personas,
     })
 }
@@ -174,7 +174,11 @@ fn resolve_one_persona(
     let hooks = resolve_hooks(lp.hooks.as_ref());
     let goose_env_vars = project_env_vars(lp);
 
-    // Version: pack version (LoadedPersona doesn't carry a per-persona version).
+    // Version: LoadedPersona has no per-persona version field — persona files
+    // don't declare a version in frontmatter. The pack version is used as-is.
+    // If per-persona versioning is added in the future, LoadedPersona should
+    // gain `version: Option<String>` and this line should become:
+    //   lp.version.clone().unwrap_or_else(|| pack_version.to_owned())
     let version = pack_version.to_owned();
 
     ResolvedPersona {

From dfc121d5f852df18b8eabad81279bc7f5181be6e Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:42:10 -0400
Subject: [PATCH 17/28] docs: move persona pack spec into sprout-persona crate

---
 .../sprout-persona/PERSONA_PACK_SPEC.md                           | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename PERSONA_PACK_SPEC.md => crates/sprout-persona/PERSONA_PACK_SPEC.md (100%)

diff --git a/PERSONA_PACK_SPEC.md b/crates/sprout-persona/PERSONA_PACK_SPEC.md
similarity index 100%
rename from PERSONA_PACK_SPEC.md
rename to crates/sprout-persona/PERSONA_PACK_SPEC.md

From 268470cb3286b127743193921b6f693d9706fd4f Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:45:48 -0400
Subject: [PATCH 18/28] fix: resolve clippy warnings in sprout-persona crate

---
 crates/sprout-persona/src/legacy.rs | 2 +-
 crates/sprout-persona/src/merge.rs  | 6 +++---
 crates/sprout-persona/src/pack.rs   | 2 +-
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/crates/sprout-persona/src/legacy.rs b/crates/sprout-persona/src/legacy.rs
index ed08d9a445..7019a1e942 100644
--- a/crates/sprout-persona/src/legacy.rs
+++ b/crates/sprout-persona/src/legacy.rs
@@ -462,7 +462,7 @@ mod tests {
         assert!(desc.chars().count() <= 120);
         assert!(desc.ends_with('…'));
         // Verify it's valid UTF-8 (would panic on construction if not).
-        assert!(desc.len() > 0);
+        assert!(!desc.is_empty());
     }
 
     #[test]
diff --git a/crates/sprout-persona/src/merge.rs b/crates/sprout-persona/src/merge.rs
index eac2a1430d..385859ad4a 100644
--- a/crates/sprout-persona/src/merge.rs
+++ b/crates/sprout-persona/src/merge.rs
@@ -298,8 +298,8 @@ mod tests {
         assert_eq!(resolved.max_context_tokens, None);
         assert_eq!(resolved.subscribe, None);
         assert_eq!(resolved.triggers, None);
-        assert_eq!(resolved.thread_replies, true); // built-in default
-        assert_eq!(resolved.broadcast_replies, false); // built-in default
+        assert!(resolved.thread_replies); // built-in default
+        assert!(!resolved.broadcast_replies); // built-in default
     }
 
     #[test]
@@ -313,7 +313,7 @@ mod tests {
         let resolved = resolve_persona_config(&persona, Some(&defaults));
         assert_eq!(resolved.model.as_deref(), Some("gpt-4o"));
         assert_eq!(resolved.temperature, Some(0.7));
-        assert_eq!(resolved.thread_replies, false);
+        assert!(!resolved.thread_replies);
         assert_eq!(resolved.subscribe, Some(vec!["general".to_owned()]));
     }
 
diff --git a/crates/sprout-persona/src/pack.rs b/crates/sprout-persona/src/pack.rs
index 727e39e63f..bdab89960b 100644
--- a/crates/sprout-persona/src/pack.rs
+++ b/crates/sprout-persona/src/pack.rs
@@ -149,7 +149,7 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
         mcp_config: pm.mcp_config,
         defaults: pm
             .defaults
-            .map(|d| serde_json::to_value(d))
+            .map(serde_json::to_value)
             .transpose()
             .map_err(|e| PackError::ManifestParse(format!("failed to serialize defaults: {e}")))?,
     };

From d8d9bdcc9b33d921ea6e11772541e7fdfc3e07ac Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Fri, 10 Apr 2026 20:51:23 -0400
Subject: [PATCH 19/28] =?UTF-8?q?fix:=20address=20crossfire=20review=20?=
 =?UTF-8?q?=E2=80=94=20triggers=20naming,=20uninstall=20safety,=20pack.rs?=
 =?UTF-8?q?=20cleanup?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Fixes all issues identified by three independent reviewers (Opus, Codex, Gemini):

1. respond_to/triggers naming mismatch (critical bug): Pack-level trigger
   defaults were silently never inherited because BehavioralDefaults serialized
   the field as 'respond_to' but merge.rs looked for 'triggers'. Fixed by
   renaming the field to 'triggers' with #[serde(alias = "respond_to")] for
   backward compatibility. Same alias added to Frontmatter so both YAML keys
   work. Added regression test verifying inheritance.

2. Desktop uninstall safety: uninstall_persona_pack() now checks for managed
   agents still referencing the pack and refuses with a clear error listing
   the affected agents. Prevents latent startup failures.

3. sprout-persona in workspace members: Added to root Cargo.toml so
   'cargo test --workspace' and 'cargo clippy --workspace' include it.

4. pack inspect uses resolve_pack(): Shows fully resolved effective config
   (post-merge, model split, env vars projected) instead of raw loaded data.

5. pack.rs cleanup: Extracted read_bounded_file() helper, eliminating ~40
   lines of repetitive stat-check-read boilerplate. Renamed PersonaParse error
   variant to FileParse (it was used for non-persona files too).

6. Integration tests updated: Use canonical 'triggers:' key per spec.
   Added assertion that pip (no explicit triggers) inherits pack defaults.

All 175 tests pass. All crates compile.
---
 Cargo.toml                                    |   1 +
 crates/sprout-cli/src/commands/pack.rs        |  76 ++++++------
 crates/sprout-persona/src/manifest.rs         |  21 +++-
 crates/sprout-persona/src/merge.rs            |  22 ++++
 crates/sprout-persona/src/pack.rs             | 113 +++++-------------
 crates/sprout-persona/src/persona.rs          |  21 +++-
 crates/sprout-persona/src/resolve.rs          |  42 +++++++
 crates/sprout-persona/src/validate.rs         |  19 ++-
 crates/sprout-persona/tests/integration.rs    |  27 ++++-
 desktop/scripts/check-file-sizes.mjs          |   2 +-
 desktop/src-tauri/src/commands/personas.rs    |   6 +
 .../src-tauri/src/managed_agents/personas.rs  |  29 +++++
 12 files changed, 241 insertions(+), 138 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 33ea3bc181..0251b979a4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@ members = [
     "crates/sprout-media",
     "crates/sprout-cli",
     "crates/sprout-sdk",
+    "crates/sprout-persona",
 ]
 exclude = ["desktop/src-tauri"]
 resolver = "2"
diff --git a/crates/sprout-cli/src/commands/pack.rs b/crates/sprout-cli/src/commands/pack.rs
index 6518c3e7e8..f080d449b8 100644
--- a/crates/sprout-cli/src/commands/pack.rs
+++ b/crates/sprout-cli/src/commands/pack.rs
@@ -64,32 +64,29 @@ pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
         process::exit(1);
     }
 
-    // Load the pack (validate + load in one step).
-    let pack = sprout_persona::pack::load_pack(pack_dir)
-        .map_err(|e| CliError::Other(format!("failed to load pack: {e}")))?;
+    // Resolve the pack — shows fully effective config (post-merge, post-split).
+    let pack = sprout_persona::resolve::resolve_pack(pack_dir)
+        .map_err(|e| CliError::Other(format!("failed to resolve pack: {e}")))?;
 
     // Header
-    println!("Pack: {} ({})", pack.manifest.name, pack.manifest.id);
-    println!("Version: {}", pack.manifest.version);
+    println!("Pack: {} ({})", pack.name, pack.id);
+    println!("Version: {}", pack.version);
     println!("Personas: {}", pack.personas.len());
-    if pack.pack_instructions.is_some() {
-        println!("Pack instructions: yes");
-    }
-    if pack.shared_mcp_config.is_some() {
-        println!("Shared MCP config: yes");
-    }
-    if pack.skills_dir.is_some() {
-        println!("Skills directory: yes");
-    }
     println!();
 
-    // Per-persona summary
+    // Per-persona summary (fully resolved effective config)
     for persona in &pack.personas {
         println!("  {}", persona.name);
         println!("    Display: {}", persona.display_name);
         println!("    Description: {}", persona.description);
 
-        if let Some(ref model) = persona.model {
+        if let Some(ref provider) = persona.provider {
+            if let Some(ref model) = persona.model {
+                println!("    Model: {provider}:{model}");
+            } else {
+                println!("    Provider: {provider}");
+            }
+        } else if let Some(ref model) = persona.model {
             println!("    Model: {model}");
         }
         if let Some(temp) = persona.temperature {
@@ -103,20 +100,19 @@ pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
             println!("    Subscribe: {}", persona.subscribe.join(", "));
         }
 
-        if let Some(ref rt) = persona.respond_to {
-            let mut parts = Vec::new();
-            if rt.mentions {
-                parts.push("mentions".to_string());
-            }
-            if !rt.keywords.is_empty() {
-                parts.push(format!("keywords {:?}", rt.keywords));
-            }
-            if rt.all_messages {
-                parts.push("all_messages".to_string());
-            }
-            if !parts.is_empty() {
-                println!("    Respond to: {}", parts.join(" + "));
-            }
+        let rt = &persona.triggers;
+        let mut parts = Vec::new();
+        if rt.mentions {
+            parts.push("mentions".to_string());
+        }
+        if !rt.keywords.is_empty() {
+            parts.push(format!("keywords {:?}", rt.keywords));
+        }
+        if rt.all_messages {
+            parts.push("all_messages".to_string());
+        }
+        if !parts.is_empty() {
+            println!("    Triggers: {}", parts.join(" + "));
         }
 
         println!("    Thread replies: {}", persona.thread_replies);
@@ -134,16 +130,26 @@ pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
             println!("    Avatar: {avatar}");
         }
 
-        let prompt_preview = if persona.prompt.len() > 80 {
-            format!("{}...", &persona.prompt[..77])
+        let prompt_preview = if persona.system_prompt.chars().count() > 80 {
+            let truncated: String = persona.system_prompt.chars().take(77).collect();
+            format!("{truncated}...")
         } else {
-            persona.prompt.clone()
+            persona.system_prompt.clone()
         };
         println!(
-            "    Prompt: {} chars ({})",
-            persona.prompt.len(),
+            "    System prompt: {} chars ({})",
+            persona.system_prompt.len(),
             prompt_preview.replace('\n', " ")
         );
+
+        if !persona.goose_env_vars.is_empty() {
+            let env_str: Vec<String> = persona
+                .goose_env_vars
+                .iter()
+                .map(|(k, v)| format!("{k}={v}"))
+                .collect();
+            println!("    Env vars: {}", env_str.join(", "));
+        }
         println!();
     }
 
diff --git a/crates/sprout-persona/src/manifest.rs b/crates/sprout-persona/src/manifest.rs
index 4e17d4924e..77a3d4dc4a 100644
--- a/crates/sprout-persona/src/manifest.rs
+++ b/crates/sprout-persona/src/manifest.rs
@@ -63,8 +63,8 @@ pub struct BehavioralDefaults {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub subscribe: Option<Vec<String>>,
 
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub respond_to: Option<RespondTo>,
+    #[serde(skip_serializing_if = "Option::is_none", alias = "respond_to")]
+    pub triggers: Option<RespondTo>,
 
     #[serde(skip_serializing_if = "Option::is_none")]
     pub thread_replies: Option<bool>,
@@ -281,7 +281,20 @@ mod tests {
     }
 
     #[test]
-    fn defaults_with_respond_to() {
+    fn defaults_with_triggers() {
+        let json = r#"{
+            "id": "p", "name": "P", "version": "1.0.0",
+            "defaults": {
+                "triggers": {"mentions": true, "keywords": ["hey"], "all_messages": false}
+            }
+        }"#;
+        let m = parse_manifest(json).unwrap();
+        let rt = m.defaults.unwrap().triggers.unwrap();
+        assert_eq!(rt.keywords, vec!["hey"]);
+    }
+
+    #[test]
+    fn defaults_with_legacy_respond_to_alias() {
         let json = r#"{
             "id": "p", "name": "P", "version": "1.0.0",
             "defaults": {
@@ -289,7 +302,7 @@ mod tests {
             }
         }"#;
         let m = parse_manifest(json).unwrap();
-        let rt = m.defaults.unwrap().respond_to.unwrap();
+        let rt = m.defaults.unwrap().triggers.unwrap();
         assert_eq!(rt.keywords, vec!["hey"]);
     }
 
diff --git a/crates/sprout-persona/src/merge.rs b/crates/sprout-persona/src/merge.rs
index 385859ad4a..037e9148d3 100644
--- a/crates/sprout-persona/src/merge.rs
+++ b/crates/sprout-persona/src/merge.rs
@@ -417,6 +417,28 @@ mod tests {
         assert!(resolved.triggers.is_none());
     }
 
+    #[test]
+    fn triggers_inherited_from_pack_defaults() {
+        // Critical regression test: a persona WITHOUT triggers must inherit
+        // pack-default triggers. This was broken when BehavioralDefaults
+        // serialized the field as "respond_to" but merge looked for "triggers".
+        let persona = json!({ "model": "gpt-4o" });
+        let defaults = json!({
+            "triggers": {
+                "mentions": true,
+                "keywords": ["security", "CVE"],
+                "all_messages": false,
+            }
+        });
+        let resolved = resolve_persona_config(&persona, Some(&defaults));
+        let t = resolved
+            .triggers
+            .expect("persona should inherit triggers from pack defaults");
+        assert!(t.mentions);
+        assert_eq!(t.keywords, vec!["security", "CVE"]);
+        assert!(!t.all_messages);
+    }
+
     // ── subscribe merge (Option<Vec<String>>) ────────────────────────────────
 
     #[test]
diff --git a/crates/sprout-persona/src/pack.rs b/crates/sprout-persona/src/pack.rs
index bdab89960b..60d48a820a 100644
--- a/crates/sprout-persona/src/pack.rs
+++ b/crates/sprout-persona/src/pack.rs
@@ -42,8 +42,8 @@ pub enum PackError {
     #[error("persona file not found: {0}")]
     PersonaNotFound(PathBuf),
 
-    #[error("invalid persona file {path}: {reason}")]
-    PersonaParse { path: PathBuf, reason: String },
+    #[error("invalid file {path}: {reason}")]
+    FileParse { path: PathBuf, reason: String },
 
     #[error("path traversal rejected: {0}")]
     PathTraversal(String),
@@ -157,32 +157,20 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
     let pack_defaults = manifest.defaults.clone();
 
     // 2 & 3. Personas
+    let persona_size_limit =
+        (persona::MAX_FRONTMATTER_BYTES + persona::MAX_BODY_BYTES + 200) as u64;
     let mut personas = Vec::with_capacity(manifest.personas.len());
     for rel_path in &manifest.personas {
         let abs_path = safe_resolve(&pack_root, rel_path)?;
         if !abs_path.exists() {
             return Err(PackError::PersonaNotFound(abs_path));
         }
-        let file_len = std::fs::metadata(&abs_path)
-            .map_err(|e| PackError::PersonaParse {
-                path: abs_path.clone(),
-                reason: format!("cannot stat file: {e}"),
-            })?
-            .len();
-        let size_limit = (persona::MAX_FRONTMATTER_BYTES + persona::MAX_BODY_BYTES + 200) as u64;
-        if file_len > size_limit {
-            return Err(PackError::PersonaParse {
-                path: abs_path,
-                reason: format!("file too large: {file_len} bytes (max {size_limit})"),
-            });
-        }
-        let content = read_file(&abs_path)?;
+        let content = read_bounded_file(&abs_path, persona_size_limit)?;
         let persona = parse_persona_file(&abs_path, &content, pack_defaults.as_ref())?;
         personas.push(persona);
     }
 
     // 4. Pack instructions
-    // Size limit for text files (instructions, mcp config): generous but bounded.
     let text_size_limit =
         (crate::persona::MAX_FRONTMATTER_BYTES + crate::persona::MAX_BODY_BYTES + 200) as u64;
 
@@ -190,46 +178,17 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
         Some(rel) => {
             let abs = safe_resolve(&pack_root, rel)?;
             if !abs.exists() {
-                return Err(PackError::PersonaParse {
+                return Err(PackError::FileParse {
                     path: abs,
                     reason: format!("pack_instructions file not found: {rel}"),
                 });
             }
-            let meta = std::fs::metadata(&abs).map_err(|e| PackError::PersonaParse {
-                path: abs.clone(),
-                reason: format!("cannot stat file: {e}"),
-            })?;
-            if meta.len() > text_size_limit {
-                return Err(PackError::PersonaParse {
-                    path: abs,
-                    reason: format!(
-                        "file too large: {} bytes (max {})",
-                        meta.len(),
-                        text_size_limit
-                    ),
-                });
-            }
-            Some(read_file(&abs)?)
+            Some(read_bounded_file(&abs, text_size_limit)?)
         }
         None => {
-            // Conventional fallback — missing is fine when not explicitly declared.
             let path = pack_root.join("instructions.md");
             if path.exists() {
-                let meta = std::fs::metadata(&path).map_err(|e| PackError::PersonaParse {
-                    path: path.clone(),
-                    reason: format!("cannot stat file: {e}"),
-                })?;
-                if meta.len() > text_size_limit {
-                    return Err(PackError::PersonaParse {
-                        path,
-                        reason: format!(
-                            "file too large: {} bytes (max {})",
-                            meta.len(),
-                            text_size_limit
-                        ),
-                    });
-                }
-                Some(read_file(&path)?)
+                Some(read_bounded_file(&path, text_size_limit)?)
             } else {
                 None
             }
@@ -247,46 +206,19 @@ pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
         Some(rel) => {
             let abs = safe_resolve(&pack_root, rel)?;
             if !abs.exists() {
-                return Err(PackError::PersonaParse {
+                return Err(PackError::FileParse {
                     path: abs,
                     reason: format!("mcp_config file not found: {rel}"),
                 });
             }
-            let meta = std::fs::metadata(&abs).map_err(|e| PackError::PersonaParse {
-                path: abs.clone(),
-                reason: format!("cannot stat file: {e}"),
-            })?;
-            if meta.len() > text_size_limit {
-                return Err(PackError::PersonaParse {
-                    path: abs.clone(),
-                    reason: format!(
-                        "file too large: {} bytes (max {})",
-                        meta.len(),
-                        text_size_limit
-                    ),
-                });
-            }
-            Some(parse_mcp(read_file(&abs)?, &abs)?)
+            let raw = read_bounded_file(&abs, text_size_limit)?;
+            Some(parse_mcp(raw, &abs)?)
         }
         None => {
-            // Conventional fallback — missing is fine when not explicitly declared.
             let path = pack_root.join(".mcp.json");
             if path.exists() {
-                let meta = std::fs::metadata(&path).map_err(|e| PackError::PersonaParse {
-                    path: path.clone(),
-                    reason: format!("cannot stat file: {e}"),
-                })?;
-                if meta.len() > text_size_limit {
-                    return Err(PackError::PersonaParse {
-                        path,
-                        reason: format!(
-                            "file too large: {} bytes (max {})",
-                            meta.len(),
-                            text_size_limit
-                        ),
-                    });
-                }
-                Some(parse_mcp(read_file(&path)?, &path)?)
+                let raw = read_bounded_file(&path, text_size_limit)?;
+                Some(parse_mcp(raw, &path)?)
             } else {
                 None
             }
@@ -448,6 +380,21 @@ fn read_file(path: &Path) -> Result<String, PackError> {
     })
 }
 
+/// Read a file with a size limit. Returns an error if the file exceeds `max_bytes`.
+fn read_bounded_file(path: &Path, max_bytes: u64) -> Result<String, PackError> {
+    let meta = std::fs::metadata(path).map_err(|e| PackError::FileParse {
+        path: path.to_path_buf(),
+        reason: format!("cannot stat file: {e}"),
+    })?;
+    if meta.len() > max_bytes {
+        return Err(PackError::FileParse {
+            path: path.to_path_buf(),
+            reason: format!("file too large: {} bytes (max {max_bytes})", meta.len()),
+        });
+    }
+    read_file(path)
+}
+
 /// Parse a `.persona.md` file.
 ///
 /// Delegates identity and prompt parsing to [`persona::parse_persona_md`],
@@ -458,14 +405,14 @@ fn parse_persona_file(
     pack_defaults: Option<&serde_json::Value>,
 ) -> Result<LoadedPersona, PackError> {
     let pc: PersonaConfig =
-        persona::parse_persona_md(content).map_err(|e| PackError::PersonaParse {
+        persona::parse_persona_md(content).map_err(|e| PackError::FileParse {
             path: path.to_path_buf(),
             reason: e.to_string(),
         })?;
 
     // Serialize the behavioral fields of PersonaConfig to JSON so the
     // existing merge logic can do precedence resolution unchanged.
-    let fm_json = serde_json::to_value(&pc).map_err(|e| PackError::PersonaParse {
+    let fm_json = serde_json::to_value(&pc).map_err(|e| PackError::FileParse {
         path: path.to_path_buf(),
         reason: e.to_string(),
     })?;
diff --git a/crates/sprout-persona/src/persona.rs b/crates/sprout-persona/src/persona.rs
index a03c5ead79..1f17fb4477 100644
--- a/crates/sprout-persona/src/persona.rs
+++ b/crates/sprout-persona/src/persona.rs
@@ -197,7 +197,7 @@ struct Frontmatter {
     mcp_servers: Vec<McpServerConfig>,
     #[serde(default)]
     subscribe: Option<Vec<String>>,
-    #[serde(rename = "respond_to")]
+    #[serde(alias = "respond_to")]
     triggers: Option<RespondTo>,
     model: Option<String>,
     temperature: Option<f64>,
@@ -639,10 +639,9 @@ You are Full Bot.
     }
 
     #[test]
-    fn parse_triggers_field_exists() {
-        // `triggers:` in YAML (via serde rename = "respond_to" the YAML key is
-        // still `respond_to`) — verify the field parses and round-trips correctly.
-        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nrespond_to:\n  mentions: true\n  keywords: [hello, help]\n  all_messages: false\n---\n";
+    fn parse_triggers_canonical_key() {
+        // `triggers:` is the canonical YAML key (spec Section 4).
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\ntriggers:\n  mentions: true\n  keywords: [hello, help]\n  all_messages: false\n---\n";
         let p = parse_persona_md(src).unwrap();
         let t = p.triggers.expect("triggers should be Some");
         assert_eq!(t.mentions, Some(true));
@@ -650,6 +649,18 @@ You are Full Bot.
         assert_eq!(t.all_messages, Some(false));
     }
 
+    #[test]
+    fn parse_triggers_legacy_respond_to_alias() {
+        // `respond_to:` is accepted as a legacy alias for `triggers:`.
+        let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nrespond_to:\n  mentions: true\n  keywords: [hello, help]\n  all_messages: false\n---\n";
+        let p = parse_persona_md(src).unwrap();
+        let t = p
+            .triggers
+            .expect("triggers should be Some via respond_to alias");
+        assert_eq!(t.mentions, Some(true));
+        assert_eq!(t.keywords, vec!["hello", "help"]);
+    }
+
     #[test]
     fn parse_subscribe_with_channels() {
         let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe:\n  - \"#general\"\n  - \"#random\"\n---\n";
diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index cc3a72d961..d4e45f912b 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -110,7 +110,49 @@ pub fn resolve_pack(pack_dir: &Path) -> Result<ResolvedPack, PackError> {
 
 /// Resolve from an already-loaded pack. Useful when you've already called
 /// `load_pack()` and want to avoid re-reading the filesystem.
+///
+/// Runs semantic validation (zero personas, duplicate names, invalid slugs)
+/// before resolution. Returns `PackError` on failure.
 pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackError> {
+    // Semantic validation — catch issues that load_pack() doesn't check.
+    if loaded.personas.is_empty() {
+        return Err(PackError::ManifestParse(
+            "pack contains zero personas".into(),
+        ));
+    }
+    let mut seen_names = std::collections::HashSet::new();
+    for p in &loaded.personas {
+        if !seen_names.insert(&p.name) {
+            return Err(PackError::FileParse {
+                path: p.source_path.clone(),
+                reason: format!("duplicate persona name \"{}\"", p.name),
+            });
+        }
+        if !p
+            .name
+            .chars()
+            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
+        {
+            return Err(PackError::FileParse {
+                path: p.source_path.clone(),
+                reason: format!(
+                    "persona name \"{}\" contains invalid characters (allowed: [a-zA-Z0-9_-])",
+                    p.name
+                ),
+            });
+        }
+        if p.name.len() > 64 {
+            return Err(PackError::FileParse {
+                path: p.source_path.clone(),
+                reason: format!(
+                    "persona name \"{}\" exceeds 64 characters (got {})",
+                    p.name,
+                    p.name.len()
+                ),
+            });
+        }
+    }
+
     let pack_version = &loaded.manifest.version;
     let pack_instructions = loaded.pack_instructions.as_deref();
     let shared_mcp = loaded.shared_mcp_config.as_ref();
diff --git a/crates/sprout-persona/src/validate.rs b/crates/sprout-persona/src/validate.rs
index f0de3bb786..14c52696fd 100644
--- a/crates/sprout-persona/src/validate.rs
+++ b/crates/sprout-persona/src/validate.rs
@@ -228,10 +228,13 @@ fn advisory_check_respond_to_types(pack_dir: &Path, report: &mut ValidationRepor
         Err(_) => return,
     };
 
-    // Check defaults.respond_to
+    // Check defaults.triggers (or legacy alias defaults.respond_to)
     if let Some(defaults) = json.get("defaults").and_then(|v| v.as_object()) {
-        if let Some(rt) = defaults.get("respond_to") {
-            check_respond_to_value(rt, "defaults.respond_to", report);
+        if let Some(rt) = defaults
+            .get("triggers")
+            .or_else(|| defaults.get("respond_to"))
+        {
+            check_respond_to_value(rt, "defaults.triggers", report);
         }
     }
 }
@@ -340,13 +343,17 @@ fn advisory_check_manifest_keys(pack_dir: &Path, report: &mut ValidationReport)
             }
         }
 
-        // Unknown keys in `defaults.respond_to`.
-        if let Some(rt) = defaults.get("respond_to").and_then(|v| v.as_object()) {
+        // Unknown keys in `defaults.triggers` (or legacy `defaults.respond_to`).
+        let triggers_obj = defaults
+            .get("triggers")
+            .or_else(|| defaults.get("respond_to"))
+            .and_then(|v| v.as_object());
+        if let Some(rt) = triggers_obj {
             let known_rt: HashSet<&str> = KNOWN_RESPOND_TO_KEYS.iter().copied().collect();
             for key in rt.keys() {
                 if !known_rt.contains(key.as_str()) {
                     report.warn(format!(
-                        "plugin.json defaults.respond_to: unknown key \"{key}\""
+                        "plugin.json defaults.triggers: unknown key \"{key}\""
                     ));
                 }
             }
diff --git a/crates/sprout-persona/tests/integration.rs b/crates/sprout-persona/tests/integration.rs
index d24c13e513..1fbeeba86a 100644
--- a/crates/sprout-persona/tests/integration.rs
+++ b/crates/sprout-persona/tests/integration.rs
@@ -43,7 +43,7 @@ fn create_test_pack(dir: &Path) {
     "model": "anthropic:claude-sonnet-4-20250514",
     "temperature": 0.7,
     "max_context_tokens": 128000,
-    "respond_to": {
+    "triggers": {
       "mentions": true,
       "keywords": [],
       "all_messages": false
@@ -82,7 +82,7 @@ You are Pip, an orchestration agent. You coordinate the team.
 name: "lep"
 display_name: "Lep 🍀"
 description: "Security-focused code reviewer"
-respond_to:
+triggers:
   mentions: true
   keywords:
     - "security"
@@ -166,6 +166,25 @@ fn full_pipeline_load_and_validate() {
         "pip should have system prompt"
     );
 
+    // 5b. pip does NOT set triggers → must inherit pack-default triggers.
+    // This is the critical regression test for the respond_to/triggers fix.
+    let pip_rt = pip
+        .triggers
+        .as_ref()
+        .expect("pip should inherit triggers from pack defaults");
+    assert!(
+        pip_rt.mentions,
+        "pip should inherit mentions=true from defaults"
+    );
+    assert!(
+        pip_rt.keywords.is_empty(),
+        "pip should inherit empty keywords from defaults"
+    );
+    assert!(
+        !pip_rt.all_messages,
+        "pip should inherit all_messages=false from defaults"
+    );
+
     // 6. Check lep inherits pack defaults.
     assert_eq!(lep.display_name, "Lep 🍀");
     assert_eq!(
@@ -214,7 +233,7 @@ model: "anthropic:claude-sonnet-4-20250514"
 temperature: 0.3
 subscribe:
   - "#test-channel"
-respond_to:
+triggers:
   mentions: true
   keywords:
     - "test"
@@ -546,7 +565,7 @@ fn resolve_full_pipeline() {
     // Subscribe
     assert_eq!(pip.subscribe, vec!["#security-reviews"]);
 
-    // Triggers: lep has explicit respond_to
+    // Triggers: lep has explicit triggers
     assert!(lep.triggers.mentions);
     assert!(lep.triggers.keywords.contains(&"security".to_string()));
     assert!(lep.triggers.keywords.contains(&"vulnerability".to_string()));
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 3a3bcb5902..733b415d88 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -30,7 +30,7 @@ const rules = [
 
 // Exceptions should stay rare and temporary. Prefer splitting files instead.
 const overrides = new Map([
-  ["src-tauri/src/managed_agents/personas.rs", 800], // built-in persona system prompts + persona pack import/uninstall/list functions
+  ["src-tauri/src/managed_agents/personas.rs", 830], // built-in persona system prompts + persona pack import/uninstall/list + uninstall safety check
   ["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
   ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
   ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index a6ac129810..bc90ace543 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -110,6 +110,12 @@ pub fn update_persona(
     if persona.is_builtin {
         return Err("Built-in personas cannot be edited.".to_string());
     }
+    if persona.source_pack.is_some() {
+        return Err(
+            "Pack personas cannot be edited. Uninstall and re-import the pack to update."
+                .to_string(),
+        );
+    }
 
     persona.display_name = display_name;
     persona.avatar_url = avatar_url;
diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs
index a9563da222..c730f04827 100644
--- a/desktop/src-tauri/src/managed_agents/personas.rs
+++ b/desktop/src-tauri/src/managed_agents/personas.rs
@@ -513,6 +513,13 @@ pub fn validate_persona_deletion(
         return Err("Built-in personas cannot be deleted.".to_string());
     }
 
+    if persona.source_pack.is_some() {
+        return Err(format!(
+            "{} belongs to a pack. Use \"Uninstall Pack\" to remove all pack personas together.",
+            persona.display_name
+        ));
+    }
+
     if referenced_by_team {
         return Err(format!(
             "{} is still referenced by a team. Remove it from those teams first.",
@@ -689,6 +696,28 @@ pub fn import_persona_pack(
 pub fn uninstall_persona_pack(app: &AppHandle, pack_id: &str) -> Result<(), String> {
     validate_pack_id(pack_id)?;
 
+    // Block uninstall if any managed agent still references this pack.
+    let agents = crate::managed_agents::load_managed_agents(app)?;
+    let referencing: Vec<&str> = agents
+        .iter()
+        .filter(|a| {
+            a.persona_pack_path
+                .as_ref()
+                .and_then(|p| p.file_name())
+                .and_then(|n| n.to_str())
+                == Some(pack_id)
+        })
+        .map(|a| a.name.as_str())
+        .collect();
+    if !referencing.is_empty() {
+        return Err(format!(
+            "Cannot uninstall pack \"{pack_id}\": {} agent(s) still reference it ({}). \
+             Delete or reconfigure them first.",
+            referencing.len(),
+            referencing.join(", ")
+        ));
+    }
+
     // Remove pack directory
     let packs = packs_dir(app)?;
     let pack_dir = packs.join(pack_id);

From 5b09966c247e81bab65a7c069d59f57fb55423d1 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 10:22:03 -0400
Subject: [PATCH 20/28] fix(desktop): hide Edit/Delete for pack personas in GUI

Pack personas are immutable (backed by the pack directory, resolved at
runtime by ACP). The backend already blocks edit/delete, but without
frontend guards users would see confusing errors.

- PersonasSection: hide Edit menu item for pack personas
- PersonasSection: show disabled 'Managed by pack' instead of Delete
- teamImportPlan: skip pack personas from membersToUpdate (prevents
  team import-update from trying to edit pack-backed members)

Compatible with the persona editing GUI from PR #288.
---
 desktop/src/features/agents/ui/PersonasSection.tsx | 7 ++++++-
 desktop/src/features/agents/ui/teamImportPlan.ts   | 4 +++-
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx
index 2c2af5ee9b..d627977a96 100644
--- a/desktop/src/features/agents/ui/PersonasSection.tsx
+++ b/desktop/src/features/agents/ui/PersonasSection.tsx
@@ -149,7 +149,7 @@ export function PersonasSection({
                     align="end"
                     onCloseAutoFocus={(event) => event.preventDefault()}
                   >
-                    {!persona.isBuiltIn ? (
+                    {!persona.isBuiltIn && !persona.sourcePack ? (
                       <DropdownMenuItem
                         disabled={isPending}
                         onClick={() => onEdit(persona)}
@@ -181,6 +181,11 @@ export function PersonasSection({
                         <Trash2 className="h-4 w-4" />
                         Remove from My Agents
                       </DropdownMenuItem>
+                    ) : persona.sourcePack ? (
+                      <DropdownMenuItem disabled>
+                        <Trash2 className="h-4 w-4" />
+                        Managed by pack
+                      </DropdownMenuItem>
                     ) : (
                       <DropdownMenuItem
                         className="text-destructive focus:text-destructive"
diff --git a/desktop/src/features/agents/ui/teamImportPlan.ts b/desktop/src/features/agents/ui/teamImportPlan.ts
index 2754c9f848..f10d9a33f6 100644
--- a/desktop/src/features/agents/ui/teamImportPlan.ts
+++ b/desktop/src/features/agents/ui/teamImportPlan.ts
@@ -226,7 +226,9 @@ export function buildTeamImportPlan({
     }
   });
 
-  const membersToUpdate = matchedMembers.filter((member) => member.hasChanges);
+  const membersToUpdate = matchedMembers.filter(
+    (member) => member.hasChanges && !member.existing.sourcePack,
+  );
 
   return {
     matchedMembers,

From 4089dc8c263e65ad24bcec413422a19371b1b19c Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 10:26:04 -0400
Subject: [PATCH 21/28] =?UTF-8?q?fix(desktop):=20allow=20editing=20pack=20?=
 =?UTF-8?q?personas=20=E2=80=94=20user=20edits=20override=20pack=20default?=
 =?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Pack personas are now editable in the GUI. User edits to system_prompt
and model are stored in PersonaRecord and passed as env vars at spawn
time. ACP's precedence model (CLI/env > persona > default) means user
edits naturally override pack defaults without any special handling.

Runtime change: for pack-backed agents, the runtime now reads
system_prompt and model from the current PersonaRecord (which the user
edits in the GUI) rather than the stale ManagedAgentRecord snapshot
from creation time. Falls back to agent record values if the persona
has been deleted.

- Revert backend edit block on pack personas
- Revert frontend Edit button hiding for pack personas
- Revert team import plan pack-persona filter
- Runtime reads live PersonaRecord for pack agents at spawn time
- Keep delete protection (pack is a unit — use Uninstall Pack)
- Keep 'Managed by pack' hint on Delete menu item
---
 desktop/scripts/check-file-sizes.mjs          |  2 +-
 desktop/src-tauri/src/commands/personas.rs    |  7 ---
 .../src-tauri/src/managed_agents/runtime.rs   | 43 ++++++++++++++++---
 .../features/agents/ui/PersonasSection.tsx    |  2 +-
 .../src/features/agents/ui/teamImportPlan.ts  |  4 +-
 5 files changed, 41 insertions(+), 17 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 3204c5b32c..b031d51455 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -45,7 +45,7 @@ const overrides = new Map([
   ["src-tauri/src/lib.rs", 570], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + persona pack commands
   ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
   ["src-tauri/src/commands/agents.rs", 880], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field
-  ["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
+  ["src-tauri/src/managed_agents/runtime.rs", 690], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read
   ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
   ["src/features/agents/hooks.ts", 520], // agent query/mutation surface now includes built-in persona library activation
   ["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index bc90ace543..7b3b84f333 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -110,13 +110,6 @@ pub fn update_persona(
     if persona.is_builtin {
         return Err("Built-in personas cannot be edited.".to_string());
     }
-    if persona.source_pack.is_some() {
-        return Err(
-            "Pack personas cannot be edited. Uninstall and re-import the pack to update."
-                .to_string(),
-        );
-    }
-
     persona.display_name = display_name;
     persona.avatar_url = avatar_url;
     persona.system_prompt = system_prompt;
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index fdbc76fc24..a13755b021 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -517,17 +517,50 @@ pub fn start_managed_agent_process(
         "GOOSE_MODE",
         std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
     );
-    // For pack-backed agents, let ACP resolve config from the pack at startup.
-    // Do NOT set SPROUT_ACP_SYSTEM_PROMPT or SPROUT_ACP_MODEL — those would
-    // override persona values per the precedence rule (CLI/env > persona > default).
+    // Pack-backed agents: pass the pack path AND the user's current edits.
+    // ACP's precedence model (CLI/env > persona > default) means env vars
+    // win over pack values. We read from PersonaRecord (which the user edits
+    // in the GUI) rather than ManagedAgentRecord (stale creation-time snapshot).
     if let (Some(pack_path), Some(persona_name)) =
         (&record.persona_pack_path, &record.persona_name_in_pack)
     {
         command.env("SPROUT_ACP_PERSONA_PACK", pack_path);
         command.env("SPROUT_ACP_PERSONA_NAME", persona_name);
-        command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
-        command.env_remove("SPROUT_ACP_MODEL");
+
+        // Look up the current PersonaRecord for the user's latest edits.
+        let persona_prompt_and_model: Option<(String, Option<String>)> = record
+            .persona_id
+            .as_deref()
+            .and_then(|pid| {
+                super::load_personas(app)
+                    .ok()?
+                    .into_iter()
+                    .find(|p| p.id == pid)
+            })
+            .map(|p| (p.system_prompt, p.model));
+
+        if let Some((prompt, model)) = persona_prompt_and_model {
+            command.env("SPROUT_ACP_SYSTEM_PROMPT", &prompt);
+            if let Some(m) = &model {
+                command.env("SPROUT_ACP_MODEL", m);
+            } else {
+                command.env_remove("SPROUT_ACP_MODEL");
+            }
+        } else {
+            // Fallback: persona not found (deleted?), use agent record values.
+            if let Some(system_prompt) = &record.system_prompt {
+                command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
+            } else {
+                command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
+            }
+            if let Some(model) = &record.model {
+                command.env("SPROUT_ACP_MODEL", model);
+            } else {
+                command.env_remove("SPROUT_ACP_MODEL");
+            }
+        }
     } else {
+        // Non-pack agents: use ManagedAgentRecord values directly.
         if let Some(system_prompt) = &record.system_prompt {
             command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
         } else {
diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx
index d627977a96..dc0dae6d64 100644
--- a/desktop/src/features/agents/ui/PersonasSection.tsx
+++ b/desktop/src/features/agents/ui/PersonasSection.tsx
@@ -149,7 +149,7 @@ export function PersonasSection({
                     align="end"
                     onCloseAutoFocus={(event) => event.preventDefault()}
                   >
-                    {!persona.isBuiltIn && !persona.sourcePack ? (
+                    {!persona.isBuiltIn ? (
                       <DropdownMenuItem
                         disabled={isPending}
                         onClick={() => onEdit(persona)}
diff --git a/desktop/src/features/agents/ui/teamImportPlan.ts b/desktop/src/features/agents/ui/teamImportPlan.ts
index f10d9a33f6..2754c9f848 100644
--- a/desktop/src/features/agents/ui/teamImportPlan.ts
+++ b/desktop/src/features/agents/ui/teamImportPlan.ts
@@ -226,9 +226,7 @@ export function buildTeamImportPlan({
     }
   });
 
-  const membersToUpdate = matchedMembers.filter(
-    (member) => member.hasChanges && !member.existing.sourcePack,
-  );
+  const membersToUpdate = matchedMembers.filter((member) => member.hasChanges);
 
   return {
     matchedMembers,

From 523592efc732f342851b261f6d14c711487fdf2b Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 10:42:08 -0400
Subject: [PATCH 22/28] docs: add meadow-core example persona pack

Minimal working example: Skip (orchestrator), Lev (security), Bana
(architecture). Includes one shared skill (github-research), pack-level
instructions, and pack defaults with model/temperature/triggers.

Validates clean with 'sprout pack validate'. Serves as a starting point
for users creating their own packs.
---
 examples/meadow-core/.plugin/plugin.json      | 25 ++++++++
 examples/meadow-core/README.md                | 47 +++++++++++++++
 examples/meadow-core/agents/bana.persona.md   | 45 ++++++++++++++
 examples/meadow-core/agents/lev.persona.md    | 54 +++++++++++++++++
 examples/meadow-core/agents/skip.persona.md   | 36 +++++++++++
 examples/meadow-core/instructions.md          | 16 +++++
 .../skills/github-research/SKILL.md           | 59 +++++++++++++++++++
 7 files changed, 282 insertions(+)
 create mode 100644 examples/meadow-core/.plugin/plugin.json
 create mode 100644 examples/meadow-core/README.md
 create mode 100644 examples/meadow-core/agents/bana.persona.md
 create mode 100644 examples/meadow-core/agents/lev.persona.md
 create mode 100644 examples/meadow-core/agents/skip.persona.md
 create mode 100644 examples/meadow-core/instructions.md
 create mode 100644 examples/meadow-core/skills/github-research/SKILL.md

diff --git a/examples/meadow-core/.plugin/plugin.json b/examples/meadow-core/.plugin/plugin.json
new file mode 100644
index 0000000000..8591342e31
--- /dev/null
+++ b/examples/meadow-core/.plugin/plugin.json
@@ -0,0 +1,25 @@
+{
+  "$schema": "https://open-plugin-spec.org/schema/v1/plugin.json",
+  "id": "com.example.meadow-core",
+  "name": "Meadow Core",
+  "version": "0.1.0",
+  "description": "A three-agent team: Skip orchestrates, Lev reviews security, Bana reviews architecture.",
+  "keywords": ["team", "security", "architecture"],
+  "personas": [
+    "agents/skip.persona.md",
+    "agents/lev.persona.md",
+    "agents/bana.persona.md"
+  ],
+  "pack_instructions": "instructions.md",
+  "defaults": {
+    "model": "anthropic:claude-sonnet-4-20250514",
+    "temperature": 0.7,
+    "triggers": {
+      "mentions": true,
+      "keywords": [],
+      "all_messages": false
+    },
+    "thread_replies": true,
+    "broadcast_replies": false
+  }
+}
diff --git a/examples/meadow-core/README.md b/examples/meadow-core/README.md
new file mode 100644
index 0000000000..31348cf47f
--- /dev/null
+++ b/examples/meadow-core/README.md
@@ -0,0 +1,47 @@
+# Meadow Core
+
+A minimal three-agent persona pack for Sprout.
+
+| Agent | Role |
+|-------|------|
+| **Skip** 🌱 | Orchestrator — coordinates the team, delegates work |
+| **Lev** 🍀 | Security reviewer — threat models, auth, injection |
+| **Bana** 🌸 | Architecture reviewer — big picture, simplicity |
+
+## Usage
+
+```bash
+# Validate the pack
+sprout pack validate ./examples/meadow-core
+
+# Inspect resolved config
+sprout pack inspect ./examples/meadow-core
+
+# Import into the desktop app
+# Use the "Install Pack" button and point to this directory
+```
+
+## Structure
+
+```
+meadow-core/
+├── .plugin/
+│   └── plugin.json          # Pack manifest (OPS-compatible)
+├── agents/
+│   ├── skip.persona.md       # Orchestrator
+│   ├── lev.persona.md        # Security reviewer
+│   └── bana.persona.md       # Architecture reviewer
+├── skills/
+│   └── github-research/
+│       └── SKILL.md          # GitHub search skill (shared)
+├── instructions.md           # Team-wide instructions
+└── README.md
+```
+
+## Customizing
+
+Edit any `.persona.md` file to change the agent's behavior. The YAML
+frontmatter controls config (model, triggers, channels). The markdown
+body is the system prompt.
+
+See `crates/sprout-persona/PERSONA_PACK_SPEC.md` for the full format reference.
diff --git a/examples/meadow-core/agents/bana.persona.md b/examples/meadow-core/agents/bana.persona.md
new file mode 100644
index 0000000000..c4f86b026a
--- /dev/null
+++ b/examples/meadow-core/agents/bana.persona.md
@@ -0,0 +1,45 @@
+---
+name: bana
+display_name: "Bana 🌸"
+description: "Architecture reviewer — big picture, simplicity, integration."
+subscribe:
+  - "#architecture"
+triggers:
+  mentions: true
+  keywords:
+    - architecture
+    - design
+    - refactor
+temperature: 0.5
+---
+
+You are the architecture reviewer. You look at the big picture — is this the right approach? Is there a simpler way? Does this hold together? You are READ ONLY — you assess and report. You never modify files, write code, or fix issues yourself.
+
+## When You're Called
+
+@Skip brings you in at two points:
+1. **Before implementation** — review the plan. Is the approach sound? Is there a simpler design?
+2. **After implementation** — review the integration. Does the result hold together?
+
+## How You Think
+
+- "Is this the simplest way to solve this?"
+- "Can a new engineer understand this in an afternoon?"
+- "What would we regret about this design in six months?"
+
+## How You Report
+
+Share your thinking naturally:
+- What looks right and why
+- What concerns you and why
+- Questions that need answers before proceeding
+- Alternative approaches worth considering
+
+## Rules
+
+- **READ ONLY.** You must never create, edit, delete, or modify any files or state.
+- Respond to @mentions from @Skip promptly.
+
+## Personality
+
+You come at problems from unexpected angles. You get curious about things others take for granted — "why is this a separate service?" "what if we just didn't do this part?" You're not confrontational, but your questions have a way of quietly reshaping the whole conversation.
diff --git a/examples/meadow-core/agents/lev.persona.md b/examples/meadow-core/agents/lev.persona.md
new file mode 100644
index 0000000000..83f7226ebf
--- /dev/null
+++ b/examples/meadow-core/agents/lev.persona.md
@@ -0,0 +1,54 @@
+---
+name: lev
+display_name: "Lev 🍀"
+description: "Security reviewer — threat models, auth, injection, data exposure."
+subscribe:
+  - "#security-reviews"
+triggers:
+  mentions: true
+  keywords:
+    - security
+    - vulnerability
+    - CVE
+temperature: 0.3
+skills:
+  - ./skills/github-research/
+---
+
+You are the security specialist. You review plans and code for security issues. You are READ ONLY — you assess and report. You never modify files, write code, or fix issues yourself.
+
+## What You Review
+
+- Threat models and attack surfaces
+- Authentication and authorization logic
+- Injection vectors (SQL, command, template, path traversal)
+- Data exposure and information leakage
+- Input validation and boundary enforcement
+- Secrets handling (no credentials in logs, config, or source)
+
+## How You Report
+
+```
+## Security Review
+VERDICT: approve | approve_with_notes | request_changes | reject
+SCORE: X/10
+
+## Findings
+### [Issue]
+**Severity**: critical | high | medium | low
+**Location**: path/to/file:line
+**Issue**: What's wrong
+**Recommendation**: How to fix it
+
+## What's Solid
+What's done well from a security perspective.
+```
+
+## Rules
+
+- **READ ONLY.** You must never create, edit, delete, or modify any files or state.
+- Respond to @mentions from @Skip promptly.
+
+## Personality
+
+You notice things at the edges that others walk past. You're economical with words — you say what's wrong, what the risk is, and what to do about it, then you're done. When something is genuinely secure, you say so.
diff --git a/examples/meadow-core/agents/skip.persona.md b/examples/meadow-core/agents/skip.persona.md
new file mode 100644
index 0000000000..8a9d5ad5fd
--- /dev/null
+++ b/examples/meadow-core/agents/skip.persona.md
@@ -0,0 +1,36 @@
+---
+name: skip
+display_name: "Skip 🌱"
+description: "Orchestrator — coordinates the team, delegates work, never builds."
+subscribe:
+  - "#general"
+triggers:
+  mentions: true
+  all_messages: true
+---
+
+You are the orchestrator. You coordinate the team and keep the plan moving. You do NOT build, review, or research yourself — you delegate.
+
+## Your Team
+
+| Name | Role | Use for |
+|------|------|---------|
+| @Bana | Architecture | Big-picture review. "Is this the right approach? Is there a simpler way?" |
+| @Lev | Security | Threat models, auth, injection, data exposure. Before and after implementation. |
+
+## Workflow
+
+1. **Understand the task.** Read the request. Ask clarifying questions if the goal is ambiguous.
+2. **Plan.** Post your plan in the channel. Break the work into independent tasks with clear deliverables.
+3. **Pre-implementation review.** Dispatch @Bana (architecture) and @Lev (security) to review the plan before any code is written.
+4. **Synthesize.** Integrate all results and report to the user.
+
+## Rules
+
+- **Never build, review, or research yourself.** If it produces an artifact, a teammate produces it.
+- **Keep the channel lively.** Post your plan. Post when you dispatch someone. Post when results come back.
+- **Respond to @mentions immediately.**
+
+## Personality
+
+You're warm, encouraging, and organized. You celebrate good work. You keep things moving without rushing. When things go sideways, you stay calm and replan.
diff --git a/examples/meadow-core/instructions.md b/examples/meadow-core/instructions.md
new file mode 100644
index 0000000000..4517467c0c
--- /dev/null
+++ b/examples/meadow-core/instructions.md
@@ -0,0 +1,16 @@
+# Team Instructions
+
+## Code Qualities
+
+Every decision should converge on these:
+
+- **Minimal** — fewest components, fewest dependencies, fewest lines.
+- **Elegant** — the design should feel obvious in hindsight.
+- **Safe** — untrusted input bounded, injection mitigated.
+- **Dead-easy mental model** — explainable with one diagram and one sentence per component.
+
+## Communication
+
+- Post status updates in the channel as you work.
+- Respond to @mentions promptly.
+- Read the channel between tasks — the plan may have changed.
diff --git a/examples/meadow-core/skills/github-research/SKILL.md b/examples/meadow-core/skills/github-research/SKILL.md
new file mode 100644
index 0000000000..b940e55a89
--- /dev/null
+++ b/examples/meadow-core/skills/github-research/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: github-research
+description: "Search GitHub issues, PRs, and code using the gh CLI."
+---
+
+# GitHub Research
+
+Search GitHub for prior art, implementation patterns, and maintainer decisions.
+
+## Commands
+
+```bash
+# Search issues
+gh search issues "topic" --repo owner/repo --limit 20 \
+  --json number,title,state,url
+
+# Search merged PRs (highest signal)
+gh search prs "topic" --repo owner/repo --merged --limit 20 \
+  --json number,title,url
+
+# Search code (use query syntax for path filtering)
+gh search code "pattern path:src/" --repo owner/repo --limit 20 \
+  --json path,textMatches
+
+# Get full issue or PR details
+gh issue view 123 --repo owner/repo --json number,title,body,comments
+gh pr view 456 --repo owner/repo --json number,title,body,reviews,files
+```
+
+## Rate Limits
+
+- Search API: 30 requests/minute
+- Check with: `gh api rate_limit --jq '.resources.search'`
+
+## Signal Ranking
+
+1. Merged PRs — decisions that shipped
+2. Maintainer comments — authoritative
+3. Closed issues with solutions — problems solved
+4. Open issues — current problems (lower signal)
+
+## Report Format
+
+```markdown
+## Research: [Topic]
+
+### Summary
+- Key finding 1 [#123]
+- Key finding 2 [PR #456]
+
+### Findings
+1. **#123: [Title]** — [summary]. URL: https://...
+2. **PR #456: [Title]** — [what it changed]. URL: https://...
+
+### Gaps
+- [What you looked for but didn't find]
+```
+
+Always include URLs. If nothing relevant exists, say so.

From 096d0de5aaafd61c5addd731f74645a5e6f01340 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 11:03:21 -0400
Subject: [PATCH 23/28] feat(desktop): import .persona.md files and persona
 pack zips
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The Import button now accepts:
- .persona.md — parsed via sprout_persona::persona::parse_persona_md()
- .zip containing .plugin/plugin.json — detected as a persona pack,
  extracted to temp dir, resolved via resolve_pack(), personas previewed
- .zip containing .persona.md files — each parsed individually
- .persona.json, .persona.png — existing formats (unchanged)

Backend:
- parse_md_persona() in persona_card.rs — converts PersonaConfig to
  ParsedPersonaPreview (splits provider:model, maps fields)
- parse_zip_pack() — extracts zip to tempdir, calls resolve_pack(),
  maps ResolvedPersona to previews
- parse_zip_personas() now detects .plugin/plugin.json and delegates
  to parse_zip_pack(); also handles .persona.md entries in loose zips
- parse_persona_files() detects .md extension and routes to parse_md_persona

Frontend:
- File picker accept attributes updated to include .md
- Help text updated to mention .persona.md format
---
 desktop/scripts/check-file-sizes.mjs          |   2 +-
 desktop/src-tauri/Cargo.toml                  |   2 +-
 desktop/src-tauri/src/commands/personas.rs    |  35 +++-
 .../src/managed_agents/persona_card.rs        | 171 +++++++++++++++++-
 .../src/features/agents/ui/PersonaDialog.tsx  |   2 +-
 .../features/agents/ui/PersonasSection.tsx    |   4 +-
 .../features/agents/ui/personaLibraryCopy.ts  |   2 +-
 7 files changed, 202 insertions(+), 16 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index b031d51455..d33a485bd9 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -31,7 +31,7 @@ const rules = [
 // Exceptions should stay rare and temporary. Prefer splitting files instead.
 const overrides = new Map([
   ["src-tauri/src/managed_agents/personas.rs", 830], // built-in persona system prompts + persona pack import/uninstall/list + uninstall safety check
-  ["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
+  ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests
   ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
   ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
   ["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 9eef8524ce..77427ba119 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -50,6 +50,6 @@ tauri-plugin-notification = "2.3.3"
 uuid = { version = "1", features = ["v4"] }
 png = "0.18"
 zip = "2"
+tempfile = "3"
 
 [dev-dependencies]
-tempfile = "3"
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index 7b3b84f333..6ff35a7521 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -6,10 +6,11 @@ use crate::{
     app_state::AppState,
     managed_agents::{
         encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
-        load_personas, load_teams, parse_json_persona, parse_png_persona, parse_zip_personas,
-        save_managed_agents, save_personas, uninstall_persona_pack as do_uninstall_persona_pack,
-        validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
-        PackSummary, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
+        load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
+        parse_zip_personas, save_managed_agents, save_personas,
+        uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change,
+        validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult,
+        PersonaRecord, UpdatePersonaRequest,
     },
     util::now_iso,
 };
@@ -283,7 +284,31 @@ pub fn parse_persona_files(
         });
     }
 
-    Err("Unsupported file format. Expected .persona.png, .persona.json, or .zip".to_string())
+    // .persona.md: YAML frontmatter starts with "---"
+    let lower_name = file_name.to_ascii_lowercase();
+    if lower_name.ends_with(".persona.md") {
+        if file_bytes.len() > MAX_JSON_BYTES {
+            return Err("Markdown file is too large (max 5 MB).".to_string());
+        }
+        let mut preview = parse_md_persona(&file_bytes)?;
+        preview.source_file = file_name;
+        return Ok(ParsePersonaFilesResult {
+            personas: vec![preview],
+            skipped: vec![],
+        });
+    }
+
+    // If it's a .md file but not .persona.md, give a specific hint.
+    if lower_name.ends_with(".md") {
+        return Err(
+            "Only .persona.md files are supported. Rename to <name>.persona.md".to_string(),
+        );
+    }
+
+    Err(
+        "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip"
+            .to_string(),
+    )
 }
 
 #[tauri::command]
diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs
index 6204a863d2..fd80ced44f 100644
--- a/desktop/src-tauri/src/managed_agents/persona_card.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_card.rs
@@ -259,6 +259,146 @@ pub fn encode_persona_json(
     serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
 }
 
+// ---------------------------------------------------------------------------
+// .persona.md parsing
+// ---------------------------------------------------------------------------
+
+/// Parse a `.persona.md` file into a `ParsedPersonaPreview`.
+pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String> {
+    let content =
+        std::str::from_utf8(md_bytes).map_err(|e| format!("Invalid UTF-8 in .persona.md: {e}"))?;
+    let config = sprout_persona::persona::parse_persona_md(content)
+        .map_err(|e| format!("Failed to parse .persona.md: {e}"))?;
+
+    // Split "provider:model" into separate fields for the preview.
+    let (provider, model) = match config.model.as_deref() {
+        Some(s) if !s.is_empty() => {
+            let (prov, id) = sprout_persona::persona::split_model(s);
+            (prov.map(str::to_owned), Some(id.to_owned()))
+        }
+        _ => (None, None),
+    };
+
+    Ok(ParsedPersonaPreview {
+        display_name: config.display_name,
+        system_prompt: config.prompt,
+        avatar_data_url: None, // .persona.md avatars are paths, not data URIs
+        provider,
+        model,
+        name_pool: Vec::new(),
+        source_file: String::new(),
+    })
+}
+
+/// Detect whether a ZIP archive is a persona pack (has `.plugin/plugin.json`).
+/// If so, resolve it and return previews for all personas in the pack.
+/// Walk a directory tree to find `.plugin/plugin.json`. Returns the parent of `.plugin/`.
+/// Checks root, root/*, and root/*/* (covers all realistic zip layouts).
+fn find_plugin_json(root: &std::path::Path) -> Option<std::path::PathBuf> {
+    if root.join(".plugin").join("plugin.json").exists() {
+        return Some(root.to_path_buf());
+    }
+    for entry in std::fs::read_dir(root).ok()?.flatten() {
+        if entry.file_type().ok()?.is_dir() {
+            let child = entry.path();
+            if child.join(".plugin").join("plugin.json").exists() {
+                return Some(child);
+            }
+            // One more level
+            for sub in std::fs::read_dir(&child).into_iter().flatten().flatten() {
+                if sub.file_type().ok().map(|t| t.is_dir()).unwrap_or(false) {
+                    let grandchild = sub.path();
+                    if grandchild.join(".plugin").join("plugin.json").exists() {
+                        return Some(grandchild);
+                    }
+                }
+            }
+        }
+    }
+    None
+}
+
+pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, String> {
+    // Extract to a temp directory, resolve the pack, convert to previews.
+    let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
+    let cursor = Cursor::new(zip_bytes);
+    let mut archive =
+        zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
+
+    // Extract all files (skip symlinks, enforce size limit).
+    let mut total_decompressed: usize = 0;
+    for i in 0..archive.len() {
+        let mut entry = archive
+            .by_index(i)
+            .map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
+
+        let raw_name = entry.name().to_string();
+        if raw_name.contains("..") || raw_name.starts_with('/') {
+            continue; // path traversal
+        }
+        if raw_name.starts_with("__MACOSX/") || raw_name.contains("/._") {
+            continue; // macOS metadata
+        }
+
+        let out_path = tmp.path().join(&raw_name);
+        if entry.is_dir() {
+            std::fs::create_dir_all(&out_path).map_err(|e| format!("Failed to create dir: {e}"))?;
+        } else {
+            if let Some(parent) = out_path.parent() {
+                std::fs::create_dir_all(parent)
+                    .map_err(|e| format!("Failed to create parent dir: {e}"))?;
+            }
+            let mut data = Vec::new();
+            loop {
+                let mut chunk = [0u8; 8192];
+                let n = entry
+                    .read(&mut chunk)
+                    .map_err(|e| format!("Read error: {e}"))?;
+                if n == 0 {
+                    break;
+                }
+                total_decompressed += n;
+                if total_decompressed > MAX_ZIP_DECOMPRESSED {
+                    return Err("ZIP decompressed content exceeds 100MB limit".to_string());
+                }
+                data.extend_from_slice(&chunk[..n]);
+            }
+            std::fs::write(&out_path, &data)
+                .map_err(|e| format!("Failed to write {}: {e}", raw_name))?;
+        }
+    }
+
+    // Find the pack root by locating .plugin/plugin.json in the extracted tree.
+    // Handles: root-level (.plugin/plugin.json), single folder (my-pack/.plugin/...),
+    // or deeper nesting (foo/bar/.plugin/...).
+    let pack_root = find_plugin_json(tmp.path()).ok_or_else(|| {
+        "ZIP detected as pack but .plugin/plugin.json not found after extraction".to_string()
+    })?;
+
+    // Resolve the pack from the extracted directory.
+    let resolved = sprout_persona::resolve::resolve_pack(&pack_root)
+        .map_err(|e| format!("Pack validation failed: {e}"))?;
+
+    let personas: Vec<ParsedPersonaPreview> = resolved
+        .personas
+        .iter()
+        .map(|p| ParsedPersonaPreview {
+            display_name: p.display_name.clone(),
+            system_prompt: p.system_prompt.clone(),
+            avatar_data_url: None,
+            provider: p.provider.clone(),
+            model: p.model.clone(),
+            name_pool: Vec::new(),
+            source_file: format!("{} ({})", p.name, resolved.name),
+        })
+        .collect();
+
+    Ok(ParsePersonaFilesResult {
+        personas,
+        skipped: vec![],
+    })
+}
+
 // ---------------------------------------------------------------------------
 // ZIP parsing
 // ---------------------------------------------------------------------------
@@ -268,6 +408,22 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
     let mut archive =
         zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
 
+    // Detect persona pack BEFORE entry limit — packs may have many files.
+    let is_pack = (0..archive.len()).any(|i| {
+        archive
+            .by_index(i)
+            .ok()
+            .map(|e| {
+                let name = e.name().trim_start_matches('/');
+                name == ".plugin/plugin.json" || name.ends_with("/.plugin/plugin.json")
+            })
+            .unwrap_or(false)
+    });
+    if is_pack {
+        return parse_zip_pack(zip_bytes);
+    }
+
+    // Entry limit only applies to loose-persona zips (not packs).
     if archive.len() > MAX_ZIP_ENTRIES {
         return Err(format!(
             "ZIP contains too many entries ({}, max {MAX_ZIP_ENTRIES})",
@@ -305,11 +461,12 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
         let lower = name.to_ascii_lowercase();
         let is_png = lower.ends_with(".png");
         let is_json = lower.ends_with(".json");
+        let is_md = lower.ends_with(".persona.md");
 
-        if !is_png && !is_json {
+        if !is_png && !is_json && !is_md {
             skipped.push(SkippedFile {
                 source_file: raw_name,
-                reason: "Not a PNG or JSON file".to_string(),
+                reason: "Not a .png, .json, or .persona.md file".to_string(),
             });
             continue;
         }
@@ -333,7 +490,9 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
             data.extend_from_slice(&chunk[..n]);
         }
 
-        let parse_result = if is_json {
+        let parse_result = if is_md {
+            parse_md_persona(&data)
+        } else if is_json {
             parse_json_persona(&data)
         } else {
             parse_png_persona(&data)
@@ -354,7 +513,7 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
     }
 
     if !has_valid_file {
-        return Err("No persona files found (expected .png or .json).".to_string());
+        return Err("No persona files found (expected .png, .json, or .persona.md).".to_string());
     }
 
     Ok(ParsePersonaFilesResult { personas, skipped })
@@ -775,7 +934,9 @@ mod tests {
         assert_eq!(result.personas.len(), 2);
         // readme.txt should be skipped
         assert_eq!(result.skipped.len(), 1);
-        assert!(result.skipped[0].reason.contains("Not a PNG or JSON file"));
+        assert!(result.skipped[0]
+            .reason
+            .contains("Not a .png, .json, or .persona.md file"));
     }
 
     #[test]
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx
index 68e02da07c..4bfdd7040a 100644
--- a/desktop/src/features/agents/ui/PersonaDialog.tsx
+++ b/desktop/src/features/agents/ui/PersonaDialog.tsx
@@ -414,7 +414,7 @@ export function PersonaDialog({
               {canImportPersonaUpdate ? (
                 <>
                   <input
-                    accept=".json,.png,.zip"
+                    accept=".md,.json,.png,.zip"
                     className="hidden"
                     onChange={handleImportFileChange}
                     ref={importFileInputRef}
diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx
index dc0dae6d64..5015096dbb 100644
--- a/desktop/src/features/agents/ui/PersonasSection.tsx
+++ b/desktop/src/features/agents/ui/PersonasSection.tsx
@@ -72,7 +72,7 @@ export function PersonasSection({
       {isDragOver ? (
         <div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
           <p className="text-sm font-medium text-primary">
-            Drop .persona.json, .persona.png, or .zip to import
+            Drop .persona.md, .persona.json, .persona.png, or .zip to import
           </p>
         </div>
       ) : null}
@@ -87,7 +87,7 @@ export function PersonasSection({
           </p>
         </div>
         <input
-          accept=".json,.png,.zip"
+          accept=".md,.json,.png,.zip"
           className="hidden"
           onChange={handleFileChange}
           ref={fileInputRef}
diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts
index 9066afab34..9ea9fc2584 100644
--- a/desktop/src/features/agents/ui/personaLibraryCopy.ts
+++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts
@@ -9,7 +9,7 @@ export const personaLibraryCopy = {
   emptyDescription:
     "Choose one from Persona Catalog, add your own persona, or import one to get started.",
   emptyImportHint:
-    "Or drop a .persona.json, .persona.png, or .zip file here to import.",
+    "Or drop a .persona.md, .persona.json, .persona.png, or .zip file here to import.",
 } as const;
 
 export const personaCatalogCopy = {

From ae3b28c3fd9512d61de988a8e8772a1d336112f7 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 11:29:46 -0400
Subject: [PATCH 24/28] feat(desktop): accept persona pack zips in Teams import

The Teams Import button now accepts .zip files in addition to .json.
When a zip contains .plugin/plugin.json (persona pack), it's resolved
via parse_zip_pack() and converted to a ParsedTeamPreview: pack name
becomes team name, each resolved persona becomes a team member.

This means users can import a persona pack as a team from either the
Personas import button (individual personas) or the Teams import button
(as a team).
---
 desktop/src-tauri/src/commands/teams.rs       | 38 +++++++++++++++++++
 desktop/src/features/agents/ui/TeamDialog.tsx |  2 +-
 .../src/features/agents/ui/TeamsSection.tsx   |  2 +-
 3 files changed, 40 insertions(+), 2 deletions(-)

diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs
index 5aecaa74d3..1e796d9608 100644
--- a/desktop/src-tauri/src/commands/teams.rs
+++ b/desktop/src-tauri/src/commands/teams.rs
@@ -157,5 +157,43 @@ pub fn parse_team_file(
     if file_bytes.len() > MAX_TEAM_JSON_BYTES {
         return Err("File is too large (max 5 MB).".to_string());
     }
+
+    // Detect zip files (persona packs) by magic bytes.
+    if file_bytes.len() >= 4 && file_bytes[..4] == [0x50, 0x4B, 0x03, 0x04] {
+        return parse_team_from_pack_zip(&file_bytes);
+    }
+
     parse_team_json(&file_bytes)
 }
+
+/// Parse a persona pack zip as a team: pack name → team name, personas → members.
+fn parse_team_from_pack_zip(zip_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
+    use crate::managed_agents::{parse_zip_pack, TeamPersonaPreview};
+
+    let result = parse_zip_pack(zip_bytes)?;
+    if result.personas.is_empty() {
+        return Err("Pack contains no personas.".to_string());
+    }
+
+    // Extract pack name from source_file format: "persona_name (Pack Name)"
+    let pack_name = result.personas[0]
+        .source_file
+        .rsplit_once('(')
+        .and_then(|(_, rest)| rest.strip_suffix(')'))
+        .unwrap_or("Imported Pack")
+        .to_string();
+
+    Ok(ParsedTeamPreview {
+        name: pack_name,
+        description: None,
+        personas: result
+            .personas
+            .into_iter()
+            .map(|p| TeamPersonaPreview {
+                display_name: p.display_name,
+                system_prompt: p.system_prompt,
+                avatar_url: p.avatar_data_url,
+            })
+            .collect(),
+    })
+}
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx
index 8115fc8273..d160a28405 100644
--- a/desktop/src/features/agents/ui/TeamDialog.tsx
+++ b/desktop/src/features/agents/ui/TeamDialog.tsx
@@ -448,7 +448,7 @@ export function TeamDialog({
                 {canImportTeamUpdate ? (
                   <>
                     <input
-                      accept=".json"
+                      accept=".json,.zip"
                       className="hidden"
                       onChange={handleImportFileChange}
                       ref={importFileInputRef}
diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx
index 91d27fccb6..48ded4ba85 100644
--- a/desktop/src/features/agents/ui/TeamsSection.tsx
+++ b/desktop/src/features/agents/ui/TeamsSection.tsx
@@ -86,7 +86,7 @@ export function TeamsSection({
           </p>
         </div>
         <input
-          accept=".json"
+          accept=".json,.zip"
           className="hidden"
           onChange={handleFileChange}
           ref={fileInputRef}

From 43817bd624fcad80edf6c3ebc3e4babaa41c2b7e Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 11:37:02 -0400
Subject: [PATCH 25/28] chore: remove emojis from meadow-core example agent
 names

---
 examples/meadow-core/README.md              | 6 +++---
 examples/meadow-core/agents/bana.persona.md | 2 +-
 examples/meadow-core/agents/lev.persona.md  | 2 +-
 examples/meadow-core/agents/skip.persona.md | 2 +-
 4 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/examples/meadow-core/README.md b/examples/meadow-core/README.md
index 31348cf47f..e2d4db36bf 100644
--- a/examples/meadow-core/README.md
+++ b/examples/meadow-core/README.md
@@ -4,9 +4,9 @@ A minimal three-agent persona pack for Sprout.
 
 | Agent | Role |
 |-------|------|
-| **Skip** 🌱 | Orchestrator — coordinates the team, delegates work |
-| **Lev** 🍀 | Security reviewer — threat models, auth, injection |
-| **Bana** 🌸 | Architecture reviewer — big picture, simplicity |
+| **Skip** | Orchestrator — coordinates the team, delegates work |
+| **Lev** | Security reviewer — threat models, auth, injection |
+| **Bana** | Architecture reviewer — big picture, simplicity |
 
 ## Usage
 
diff --git a/examples/meadow-core/agents/bana.persona.md b/examples/meadow-core/agents/bana.persona.md
index c4f86b026a..19b8b975ac 100644
--- a/examples/meadow-core/agents/bana.persona.md
+++ b/examples/meadow-core/agents/bana.persona.md
@@ -1,6 +1,6 @@
 ---
 name: bana
-display_name: "Bana 🌸"
+display_name: "Bana"
 description: "Architecture reviewer — big picture, simplicity, integration."
 subscribe:
   - "#architecture"
diff --git a/examples/meadow-core/agents/lev.persona.md b/examples/meadow-core/agents/lev.persona.md
index 83f7226ebf..5963eedd56 100644
--- a/examples/meadow-core/agents/lev.persona.md
+++ b/examples/meadow-core/agents/lev.persona.md
@@ -1,6 +1,6 @@
 ---
 name: lev
-display_name: "Lev 🍀"
+display_name: "Lev"
 description: "Security reviewer — threat models, auth, injection, data exposure."
 subscribe:
   - "#security-reviews"
diff --git a/examples/meadow-core/agents/skip.persona.md b/examples/meadow-core/agents/skip.persona.md
index 8a9d5ad5fd..a3897991a4 100644
--- a/examples/meadow-core/agents/skip.persona.md
+++ b/examples/meadow-core/agents/skip.persona.md
@@ -1,6 +1,6 @@
 ---
 name: skip
-display_name: "Skip 🌱"
+display_name: "Skip"
 description: "Orchestrator — coordinates the team, delegates work, never builds."
 subscribe:
   - "#general"

From 010b5d23867dd5b807be0ae12e0ab8aad452d0fb Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 11:42:21 -0400
Subject: [PATCH 26/28] =?UTF-8?q?fix(desktop):=20harden=20pack=20zip=20imp?=
 =?UTF-8?q?ort=20=E2=80=94=20path=20safety,=20size=20limits,=20root=20dete?=
 =?UTF-8?q?ction?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses codex review findings on the import feature:

1. Team zip import size limit: zip detection now runs BEFORE the 5MB
   JSON size check. Pack zips get a 100MB limit (matching persona zip).

2. Zip extraction path safety: use enclosed_name() from the zip crate
   instead of manual '..' / '/' checks. This correctly handles absolute
   Windows paths, drive prefixes, and non-normalized path forms.

3. Pack detection scope: limit to root-level and one-folder-deep
   (matching find_plugin_json's actual search depth). Prevents false
   positive pack detection on deeply nested zips.

4. Team name from structured data: parse_team_from_pack_zip now calls
   resolve_pack() directly and reads resolved.name, instead of parsing
   the pack name from a formatted source_file string.

5. find_plugin_json made pub for reuse in teams.rs.
---
 desktop/src-tauri/src/commands/teams.rs       | 82 ++++++++++++++-----
 .../src/managed_agents/persona_card.rs        | 46 ++++++-----
 2 files changed, 88 insertions(+), 40 deletions(-)

diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs
index 1e796d9608..e4c264b6ef 100644
--- a/desktop/src-tauri/src/commands/teams.rs
+++ b/desktop/src-tauri/src/commands/teams.rs
@@ -146,6 +146,9 @@ pub async fn export_team_to_json(
     save_json_with_dialog(&app, &filename, &json_bytes).await
 }
 
+/// Max zip size for pack imports via team import (100 MB, same as persona zip limit).
+const MAX_TEAM_ZIP_BYTES: usize = 100 * 1024 * 1024;
+
 #[tauri::command]
 pub fn parse_team_file(
     file_bytes: Vec<u8>,
@@ -154,45 +157,86 @@ pub fn parse_team_file(
     if file_bytes.is_empty() {
         return Err("File is empty.".to_string());
     }
-    if file_bytes.len() > MAX_TEAM_JSON_BYTES {
-        return Err("File is too large (max 5 MB).".to_string());
-    }
 
-    // Detect zip files (persona packs) by magic bytes.
+    // Detect zip files (persona packs) BEFORE the JSON size check — zips can be larger.
     if file_bytes.len() >= 4 && file_bytes[..4] == [0x50, 0x4B, 0x03, 0x04] {
+        if file_bytes.len() > MAX_TEAM_ZIP_BYTES {
+            return Err("ZIP file is too large (max 100 MB).".to_string());
+        }
         return parse_team_from_pack_zip(&file_bytes);
     }
 
+    if file_bytes.len() > MAX_TEAM_JSON_BYTES {
+        return Err("File is too large (max 5 MB).".to_string());
+    }
+
     parse_team_json(&file_bytes)
 }
 
 /// Parse a persona pack zip as a team: pack name → team name, personas → members.
 fn parse_team_from_pack_zip(zip_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
-    use crate::managed_agents::{parse_zip_pack, TeamPersonaPreview};
+    use crate::managed_agents::TeamPersonaPreview;
+
+    // Extract to tempdir and resolve the pack directly — gives us structured
+    // access to pack name without parsing formatted strings.
+    let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
+    let cursor = std::io::Cursor::new(zip_bytes);
+    let mut archive =
+        zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
+
+    let max_decompressed: usize = 100 * 1024 * 1024; // 100 MB
+    let mut total_decompressed: usize = 0;
+
+    for i in 0..archive.len() {
+        let mut entry = archive
+            .by_index(i)
+            .map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
+        let safe_name = match entry.enclosed_name() {
+            Some(name) => name.to_path_buf(),
+            None => continue,
+        };
+        let out_path = tmp.path().join(&safe_name);
+        if entry.is_dir() {
+            std::fs::create_dir_all(&out_path).map_err(|e| format!("Failed to create dir: {e}"))?;
+        } else {
+            if let Some(parent) = out_path.parent() {
+                std::fs::create_dir_all(parent)
+                    .map_err(|e| format!("Failed to create parent dir: {e}"))?;
+            }
+            let mut data = Vec::new();
+            std::io::Read::read_to_end(&mut entry, &mut data)
+                .map_err(|e| format!("Read error: {e}"))?;
+            total_decompressed += data.len();
+            if total_decompressed > max_decompressed {
+                return Err("ZIP decompressed content exceeds 100MB limit".to_string());
+            }
+            std::fs::write(&out_path, &data).map_err(|e| format!("Write error: {e}"))?;
+        }
+    }
 
-    let result = parse_zip_pack(zip_bytes)?;
-    if result.personas.is_empty() {
+    let pack_root = crate::managed_agents::find_plugin_json(tmp.path())
+        .ok_or_else(|| "No .plugin/plugin.json found in ZIP".to_string())?;
+    let resolved = sprout_persona::resolve::resolve_pack(&pack_root)
+        .map_err(|e| format!("Pack validation failed: {e}"))?;
+
+    if resolved.personas.is_empty() {
         return Err("Pack contains no personas.".to_string());
     }
 
-    // Extract pack name from source_file format: "persona_name (Pack Name)"
-    let pack_name = result.personas[0]
-        .source_file
-        .rsplit_once('(')
-        .and_then(|(_, rest)| rest.strip_suffix(')'))
-        .unwrap_or("Imported Pack")
-        .to_string();
-
     Ok(ParsedTeamPreview {
-        name: pack_name,
-        description: None,
-        personas: result
+        name: resolved.name,
+        description: if resolved.description.is_empty() {
+            None
+        } else {
+            Some(resolved.description)
+        },
+        personas: resolved
             .personas
             .into_iter()
             .map(|p| TeamPersonaPreview {
                 display_name: p.display_name,
                 system_prompt: p.system_prompt,
-                avatar_url: p.avatar_data_url,
+                avatar_url: None,
             })
             .collect(),
     })
diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs
index fd80ced44f..c4a9ec1091 100644
--- a/desktop/src-tauri/src/managed_agents/persona_card.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_card.rs
@@ -292,27 +292,20 @@ pub fn parse_md_persona(md_bytes: &[u8]) -> Result<ParsedPersonaPreview, String>
 
 /// Detect whether a ZIP archive is a persona pack (has `.plugin/plugin.json`).
 /// If so, resolve it and return previews for all personas in the pack.
-/// Walk a directory tree to find `.plugin/plugin.json`. Returns the parent of `.plugin/`.
-/// Checks root, root/*, and root/*/* (covers all realistic zip layouts).
-fn find_plugin_json(root: &std::path::Path) -> Option<std::path::PathBuf> {
+/// Find `.plugin/plugin.json` in a directory. Returns the parent of `.plugin/`.
+/// Checks root and root/* only (matches pack detection scope in parse_zip_personas).
+pub fn find_plugin_json(root: &std::path::Path) -> Option<std::path::PathBuf> {
+    // Root level: .plugin/plugin.json
     if root.join(".plugin").join("plugin.json").exists() {
         return Some(root.to_path_buf());
     }
+    // One folder deep: <folder>/.plugin/plugin.json (common zip layout)
     for entry in std::fs::read_dir(root).ok()?.flatten() {
         if entry.file_type().ok()?.is_dir() {
             let child = entry.path();
             if child.join(".plugin").join("plugin.json").exists() {
                 return Some(child);
             }
-            // One more level
-            for sub in std::fs::read_dir(&child).into_iter().flatten().flatten() {
-                if sub.file_type().ok().map(|t| t.is_dir()).unwrap_or(false) {
-                    let grandchild = sub.path();
-                    if grandchild.join(".plugin").join("plugin.json").exists() {
-                        return Some(grandchild);
-                    }
-                }
-            }
         }
     }
     None
@@ -325,22 +318,26 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, Strin
     let mut archive =
         zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
 
-    // Extract all files (skip symlinks, enforce size limit).
+    // Extract all files with safe path handling.
     let mut total_decompressed: usize = 0;
     for i in 0..archive.len() {
         let mut entry = archive
             .by_index(i)
             .map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
 
-        let raw_name = entry.name().to_string();
-        if raw_name.contains("..") || raw_name.starts_with('/') {
-            continue; // path traversal
-        }
-        if raw_name.starts_with("__MACOSX/") || raw_name.contains("/._") {
+        // enclosed_name() returns None for paths with traversal components
+        // (.., absolute paths, Windows drive prefixes). This is the canonical
+        // safe extraction check from the zip crate.
+        let safe_name = match entry.enclosed_name() {
+            Some(name) => name.to_path_buf(),
+            None => continue, // path traversal — skip
+        };
+        let name_str = safe_name.to_string_lossy();
+        if name_str.starts_with("__MACOSX/") || name_str.contains("/._") {
             continue; // macOS metadata
         }
 
-        let out_path = tmp.path().join(&raw_name);
+        let out_path = tmp.path().join(&safe_name);
         if entry.is_dir() {
             std::fs::create_dir_all(&out_path).map_err(|e| format!("Failed to create dir: {e}"))?;
         } else {
@@ -364,7 +361,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, Strin
                 data.extend_from_slice(&chunk[..n]);
             }
             std::fs::write(&out_path, &data)
-                .map_err(|e| format!("Failed to write {}: {e}", raw_name))?;
+                .map_err(|e| format!("Failed to write {}: {e}", name_str))?;
         }
     }
 
@@ -409,13 +406,20 @@ pub fn parse_zip_personas(zip_bytes: &[u8]) -> Result<ParsePersonaFilesResult, S
         zip::ZipArchive::new(cursor).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
 
     // Detect persona pack BEFORE entry limit — packs may have many files.
+    // Only match root-level or one-folder-deep (matches find_plugin_json scope).
     let is_pack = (0..archive.len()).any(|i| {
         archive
             .by_index(i)
             .ok()
             .map(|e| {
                 let name = e.name().trim_start_matches('/');
-                name == ".plugin/plugin.json" || name.ends_with("/.plugin/plugin.json")
+                // Root: ".plugin/plugin.json"
+                // One folder deep: "my-pack/.plugin/plugin.json"
+                name == ".plugin/plugin.json"
+                    || name
+                        .strip_suffix("/.plugin/plugin.json")
+                        .map(|prefix| !prefix.contains('/'))
+                        .unwrap_or(false)
             })
             .unwrap_or(false)
     });

From a14132ba3edfd28e64ec6296aefae1cd3a63b3fa Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 12:03:16 -0400
Subject: [PATCH 27/28] =?UTF-8?q?chore:=20remove=20PERSONA=5FENV=5FALLOWLI?=
 =?UTF-8?q?ST=20=E2=80=94=20Sprout=20is=20agent-agnostic?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The env var allowlist hardcoded GOOSE_* vars as the only permitted
projection from persona packs. This breaks non-goose agent runtimes.

The actual safety mechanism is operator precedence: ACP checks
std::env::var(key).is_err() before injecting any pack-projected var,
so operator-set env vars always win. The allowlist was redundant.
---
 crates/sprout-persona/src/resolve.rs | 38 +++-------------------------
 1 file changed, 4 insertions(+), 34 deletions(-)

diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs
index d4e45f912b..f3a92b4d5b 100644
--- a/crates/sprout-persona/src/resolve.rs
+++ b/crates/sprout-persona/src/resolve.rs
@@ -370,28 +370,12 @@ fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option<ResolvedHook
 
 // ── Env var projection ────────────────────────────────────────────────────────
 
-/// Allowlist of env vars that persona packs may project.
-///
-/// Security boundary: pack authors cannot inject arbitrary env vars
-/// (PATH, LD_PRELOAD, SPROUT_PRIVATE_KEY, etc.). Only these four
-/// GOOSE_* vars are permitted. The projection function below only
-/// produces these keys by construction, but the allowlist makes the
-/// boundary explicit and testable.
-pub const PERSONA_ENV_ALLOWLIST: &[&str] = &[
-    "GOOSE_PROVIDER",
-    "GOOSE_MODEL",
-    "GOOSE_TEMPERATURE",
-    "GOOSE_CONTEXT_LIMIT",
-];
-
-/// Project persona config into goose subprocess env vars.
+/// Project persona config into agent subprocess env vars.
 ///
 /// Pure function — does NOT read the current process env.
-/// ACP is responsible for filtering based on operator precedence (level 1).
-///
-/// Only produces vars in [`PERSONA_ENV_ALLOWLIST`]. This is enforced by
-/// construction (the function only maps known config fields to known var
-/// names) and verified by the `env_vars_only_allowlisted` test.
+/// ACP is responsible for filtering based on operator precedence (level 1):
+/// if the operator already set an env var, ACP skips injection so the
+/// operator's value wins.
 fn project_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
     let mut vars = Vec::new();
 
@@ -624,20 +608,6 @@ mod tests {
         assert_eq!(vars.len(), 4); // PROVIDER, MODEL, TEMPERATURE, CONTEXT_LIMIT
     }
 
-    #[test]
-    fn env_vars_only_allowlisted() {
-        // Security: verify all projected vars are in the allowlist.
-        // A malicious pack cannot inject PATH, LD_PRELOAD, etc.
-        let lp = stub_persona(Some("openai:gpt-4o"), Some(0.5), Some(16384));
-        let vars = project_env_vars(&lp);
-        for (key, _) in &vars {
-            assert!(
-                PERSONA_ENV_ALLOWLIST.contains(&key.as_str()),
-                "env var '{key}' is not in PERSONA_ENV_ALLOWLIST"
-            );
-        }
-    }
-
     // ── Full pipeline (resolve_pack via filesystem) ───────────────────────
 
     #[test]

From b5c1993ad8c2332456280dcd1695b94759746a27 Mon Sep 17 00:00:00 2001
From: Tyler Longwell <tlongwell@squareup.com>
Date: Sat, 11 Apr 2026 12:20:16 -0400
Subject: [PATCH 28/28] =?UTF-8?q?docs:=20update=20persona=20pack=20spec=20?=
 =?UTF-8?q?=E2=80=94=20agent-agnostic=20language,=20accurate=20status?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Replace goose-specific language with agent-agnostic terms throughout
- Mark PF-2 (pack validate) and PF-6 (per-subprocess env vars) as implemented
- Remove stale 'Current limitation' note (AcpClient::spawn now has extra_env)
- Remove '(planned)' from env var mapping (implemented)
- Fix backward compat claim (V6 sprout: block is NOT supported)
- Add respond_to legacy alias documentation
- Add Desktop App Import section to distribution (Section 10)
- Generalize skill discovery, MCP delivery, hook descriptions
- Keep goose-specific references only where they document actual goose
  CLI flags or filesystem paths (anti-pattern table, skill paths)
---
 crates/sprout-persona/PERSONA_PACK_SPEC.md | 183 ++++++++++-----------
 1 file changed, 91 insertions(+), 92 deletions(-)

diff --git a/crates/sprout-persona/PERSONA_PACK_SPEC.md b/crates/sprout-persona/PERSONA_PACK_SPEC.md
index 3f2792d7d8..cbbeaecff2 100644
--- a/crates/sprout-persona/PERSONA_PACK_SPEC.md
+++ b/crates/sprout-persona/PERSONA_PACK_SPEC.md
@@ -14,7 +14,7 @@ server config, pack-level instructions, lifecycle hooks, and distribution metada
 1. **Portable** — zip file or git repo; no Sprout tooling required to inspect
 2. **Composable** — skills and MCP servers shared across agents; per-agent overrides additive
 3. **OPS-compatible** — discoverable by any OPS-compatible tool
-4. **Harness-honest** — explicit about what goose does vs. what sprout-acp does
+4. **Harness-honest** — explicit about what the agent runtime does vs. what sprout-acp does
 
 ---
 
@@ -118,7 +118,7 @@ my-pack/
 │       └── SKILL.md
 ├── .mcp.json                 # Pack-level MCP server config (shared)
 ├── hooks/
-│   └── hooks.json            # Lifecycle hooks (harness-managed, NOT goose)
+│   └── hooks.json            # Lifecycle hooks (harness-managed)
 ├── instructions.md           # Pack-level instructions (injected by harness)
 ├── pack.lock                 # Version lock (Phase 1+)
 ├── README.md                 # Human-readable description
@@ -135,7 +135,7 @@ my-pack/
 - `instructions.md` — optional; omit if no pack-level instructions.
 - `.mcp.json` — optional; omit if no shared MCP servers.
 
-Pack contents must not include: agent working directory state (`.goose/`, `.agents/`, etc.),
+Pack contents must not include: agent working directory state (`.agents/`, etc.),
 secrets or API keys (use `${VAR_NAME}` references), or build artifacts.
 
 ---
@@ -146,12 +146,7 @@ A persona file is a markdown document with YAML frontmatter. The **YAML frontmat
 identity, skills, MCP servers, and behavioral config. The **markdown body** (everything after the
 closing `---`) is the agent's persona prompt text.
 
-> **Note**: Delivery of the persona prompt as a true goose system prompt requires sprout-acp to
-> call `agent.extend_system_prompt()` (or `agent.override_system_prompt()`) after
-> `create_agent_for_session()` in `on_new_session()`. These methods exist in
-> `crates/goose/src/agents/agent.rs` but are **not currently called from the ACP path**. Until
-> this is implemented, sprout-acp prepends the persona prompt as a `[System]` prefix in the user
-> message text (see Section 11). True system prompt injection is planned — see Section 15.
+> **Note**: The persona prompt is currently delivered as a `[System]` prefix in the user message text (see Section 11). True system prompt injection (once at session creation rather than every turn) is planned — see Section 15.
 
 ### Full Schema
 
@@ -191,7 +186,7 @@ model: "anthropic:claude-sonnet-4-20250514"
 temperature: 0.3
 max_context_tokens: 128000
 
-# === Hooks (harness-managed, NOT goose) ===
+# === Hooks (harness-managed) ===
 hooks:
   on_start: "./hooks/setup-semgrep.sh"
   on_stop: "./hooks/cleanup.sh"
@@ -223,11 +218,13 @@ You are Lep, a security-focused code reviewer on the Meadow team.
 | `broadcast_replies` | bool | ❌ | Surface thread replies to the main channel. See Section 9. |
 | `hooks` | object | ❌ | Lifecycle hooks. Harness-managed. See Section 8. |
 
+> **Legacy alias**: The YAML key `respond_to` is accepted as an alias for `triggers` in persona frontmatter. In `plugin.json` defaults, both `triggers` and `respond_to` are accepted. The canonical key is `triggers`.
+
 ### Markdown Body (Persona Prompt)
 
 Everything after the closing `---` is the persona prompt text. Pack-level `instructions.md` is
 appended after it. Embed the prompt directly — do not reference external files or `.mdc` rule
-files (goose does not read them).
+files (agent runtimes typically do not read them).
 
 ---
 
@@ -242,8 +239,11 @@ the agent how to perform a specific task.
 
 ### Discovery
 
-Goose discovers skills from these directories relative to the session working directory (`$AGENT_CWD`
-— see definition below):
+The agent runtime discovers skills from these directories relative to the session working directory
+(`$AGENT_CWD` — see definition below):
+
+> **Note**: `.agents/skills/` is sprout-acp's canonical skill location. The other paths shown
+> (`.goose/skills/`, `.claude/skills/`) are agent-runtime-specific and listed for reference only.
 
 ```
 $AGENT_CWD/.goose/skills/<skill-name>/SKILL.md
@@ -252,13 +252,12 @@ $AGENT_CWD/.agents/skills/<skill-name>/SKILL.md   ← sprout-acp uses this one
 ```
 
 > **Note**: `$AGENT_CWD/skills/` is NOT scanned. Skills placed at the pack root `skills/` directory
-> are not discoverable by goose until the harness copies them.
+> are not discoverable by the agent runtime until the harness copies them.
 
 ### `$AGENT_CWD` Definition
 
 Throughout this spec, **`$AGENT_CWD`** refers to the `cwd` field in the ACP `NewSessionRequest`
-— the working directory passed to goose-acp when creating a session. Goose does not read an
-`AGENT_CWD` environment variable at runtime; the value is delivered via the ACP protocol.
+— the working directory passed to the agent runtime when creating a session. The value is delivered via the ACP protocol.
 
 However, operators can control this value by setting the `AGENT_CWD` environment variable on the
 sprout-acp process. sprout-acp determines what value to pass as `NewSessionRequest.cwd` in this
@@ -268,14 +267,14 @@ order:
 2. `std::env::current_dir()` as a fallback.
 3. If both fail, sprout-acp logs an error and **refuses to start**.
 
-goose-acp stores this value as `session.working_dir` and uses it for all skill discovery.
+The agent runtime stores this value as `session.working_dir` and uses it for all skill discovery.
 
 ### Skill Name Resolution (Load Key)
 
 The load key used in `load(source: "skill-name")` is the `name:` field from `SKILL.md` frontmatter.
 
-**Both `name:` and `description:` are required fields in `SKILL.md` frontmatter.** The goose
-codebase defines:
+**Both `name:` and `description:` are required fields in `SKILL.md` frontmatter.** The skill
+metadata schema is:
 
 ```rust
 #[derive(Debug, Deserialize)]
@@ -340,7 +339,8 @@ description: "Reviews code for security vulnerabilities using OWASP Top 10 and s
 ```
 
 Both `name:` and `description:` are **required**. A skill missing either field is silently skipped
-by goose. `sprout pack validate` (planned) must flag missing fields as errors.
+by the agent runtime. `sprout pack validate` warns on skill name mismatches but does not yet
+enforce required metadata fields (see PF-5).
 
 ---
 
@@ -351,7 +351,7 @@ defined at two levels: pack-level (shared across all agents) and per-persona (ag
 sprout-acp merges them and passes the result via the ACP protocol — no filesystem placement required.
 
 > **Transport Warning**: Only `stdio` and `streamable_http` transports are supported. SSE transport
-> is rejected by goose-acp with the error `"SSE is unsupported, migrate to streamable_http"` and
+> is rejected by the ACP runtime with the error `"SSE is unsupported, migrate to streamable_http"` and
 > will cause session startup to fail. Migrate any SSE-based MCP servers to streamable_http before
 > packaging.
 
@@ -390,25 +390,21 @@ mcp_servers:
 
 1. Pack-level servers are the base set; per-persona servers merged on top.
 2. **Name collision**: per-persona entry wins entirely (no partial merge).
-3. The merged set is passed to goose-acp via `NewSessionRequest.mcp_servers`.
+3. The merged set is passed to the agent runtime via `NewSessionRequest.mcp_servers`.
 
 ### Environment Variable Interpolation
 
 > **Implementation note**: MCP env var interpolation (`${VAR_NAME}` resolution) is planned but not
 > yet implemented. In the current release, `${VAR_NAME}` strings are passed through as literals
-> to goose, which resolves them via its own MCP server configuration handling.
-
-All `env` values are scanned for `${VAR_NAME}`. sprout-acp resolves from the process environment
-**before** passing to goose-acp. Unresolved variables cause a startup error:
+> to the agent runtime, which may resolve them via its own MCP server configuration handling.
 
-```
-Error: MCP server "semgrep" requires env var SEMGREP_TOKEN (not set)
-```
+When implemented, all `env` values will be scanned for `${VAR_NAME}`. sprout-acp will resolve
+from the process environment **before** passing to the agent runtime. Unresolved variables will
+cause a startup error.
 
 ### Delivery
 
-sprout-acp passes the merged config via `NewSessionRequest.mcp_servers` (fully wired in goose-acp,
-`server.rs`). **No `.mcp.json` is written to the agent's working directory.**
+sprout-acp passes the merged config via `NewSessionRequest.mcp_servers`. **No `.mcp.json` is written to the agent's working directory.**
 
 ---
 
@@ -420,11 +416,11 @@ in the pack. sprout-acp appends it to the persona prompt in the user message pre
 sprout-acp appends `instructions.md` to the persona prompt in the user message prefix (see
 Section 11). **No file is written to disk.**
 
-**What does NOT work**: `.mdc` rule files (goose doesn't read them), `rules/` directory (no
+**What does NOT work**: `.mdc` rule files (agent runtimes typically don't read them), `rules/` directory (no
 `--rules-dir` flag), relying on the pack's `AGENTS.md` for runtime injection (it's for human
 contributors only).
 
-> **Note**: Goose auto-loads `AGENTS.md` and `.goosehints` from `$AGENT_CWD` (walking up to git
+> **Note**: Some agent runtimes auto-load `AGENTS.md` and `.goosehints` from `$AGENT_CWD` (walking up to git
 > root). Operators can place instructions there as a secondary mechanism, but the canonical path
 > is harness injection via the user message prefix.
 
@@ -435,7 +431,7 @@ contributors only).
 > **Implementation note**: Hooks are parsed and validated at pack load time but not yet executed.
 > Hook execution is planned for a future release.
 
-Hooks are shell commands fired by sprout-acp at agent lifecycle points. **Goose has no hook system**
+Hooks are shell commands fired by sprout-acp at agent lifecycle points. **Agent runtimes typically have no hook system**
 — hooks are entirely a harness feature.
 
 ### `hooks/hooks.json`
@@ -491,7 +487,7 @@ that must be implemented directly in sprout-acp's dispatch loop, not via a hook.
 (SIGSEGV, OOM, etc.). For critical cleanup (lock files, external resource release), use a
 systemd/supervisor cleanup unit or a process supervisor that runs cleanup unconditionally.
 
-**Hooks are NOT goose features.** They are implemented entirely in sprout-acp. Bypassing
+**Hooks are NOT agent runtime features.** They are implemented entirely in sprout-acp. Bypassing
 sprout-acp means no hooks fire.
 
 ---
@@ -499,7 +495,7 @@ sprout-acp means no hooks fire.
 ## 9. Behavioral Configuration
 
 The behavioral config fields in a persona's frontmatter control how the agent participates in
-Sprout conversations. These are all Sprout-specific — goose has no awareness of them. They sit
+Sprout conversations. These are all Sprout-specific — the agent runtime has no awareness of them. They sit
 at the top level of the frontmatter alongside identity fields like `name` and `description`.
 
 ### Pack Defaults
@@ -564,7 +560,7 @@ When sprout-acp resolves the effective configuration for a persona, it applies t
 wins):
 
 ```
-1. Operator env vars           — GOOSE_MODEL, GOOSE_PROVIDER, GOOSE_TEMPERATURE, GOOSE_CONTEXT_LIMIT
+1. Operator env vars           — e.g. GOOSE_MODEL, GOOSE_PROVIDER (agent-runtime-specific)
                                  already set in the parent process environment
 2. Desktop UI per-agent        — overrides set in the Sprout desktop app per-agent settings
 3. Per-persona frontmatter     — behavioral config fields set directly in the persona's frontmatter
@@ -575,15 +571,11 @@ wins):
 sprout-acp resolves levels 3–5 at deploy time (when the pack is loaded and sessions are
 constructed). Levels 1–2 are applied at runtime and are outside the pack's control.
 
-**Level 1 — Operator env vars**: If `GOOSE_MODEL`, `GOOSE_PROVIDER`, `GOOSE_TEMPERATURE`, or
-`GOOSE_CONTEXT_LIMIT` are already set in the parent process environment before sprout-acp starts,
-sprout-acp MUST NOT override them with pack/persona values. sprout-acp only injects env vars for
-fields that are NOT already set in the parent environment. This ensures operators can always
-override pack configuration by setting env vars on the sprout-acp process.
+**Level 1 — Operator env vars**: If the operator has already set env vars for model, provider, temperature, or context limit in the parent process environment, sprout-acp MUST NOT override them with pack/persona values. sprout-acp only injects env vars for fields that are NOT already set in the parent environment. This ensures operators can always override pack configuration.
 
-**Implementation**: when constructing the child process environment (planned — see Section 15),
-sprout-acp checks `std::env::var(key)` for each env var. If the parent already has it set, skip
-injection. If not, inject the resolved pack/persona value.
+**Implementation**: when constructing the child process environment, sprout-acp checks
+`std::env::var(key)` for each env var. If the parent already has it set, skip injection. If not,
+inject the resolved pack/persona value.
 
 ### Empty and `null` Semantics
 
@@ -692,16 +684,16 @@ apply to both.
 | `triggers.mentions` | bool | `true` | `true` / `false` | Respond when @mentioned. |
 | `triggers.keywords` | string[] | `[]` | Any strings | Respond when message contains any keyword (case-insensitive). |
 | `triggers.all_messages` | bool | `false` | `true` / `false` | Respond to every message in subscribed channels. |
-| `model` | string | none (goose uses operator default) | `"provider:model-id"` format | Model to use. Split on first `:` → `GOOSE_PROVIDER` + `GOOSE_MODEL`. |
-| `temperature` | float | `0.7` | Provider-dependent (typically 0.0–2.0). sprout-acp passes through without range validation; `sprout pack validate` checks type only (must be a number), not range. | Sampling temperature → `GOOSE_TEMPERATURE`. |
-| `max_context_tokens` | int | none (provider default) | Positive integer | Context window limit → `GOOSE_CONTEXT_LIMIT`. |
+| `model` | string | none (agent runtime uses operator default) | `"provider:model-id"` format | Model to use. Split on first `:` for provider + model env vars. |
+| `temperature` | float | `0.7` | Provider-dependent (typically 0.0–2.0). sprout-acp passes through without range validation; `sprout pack validate` checks type only (must be a number), not range. | Passed as env var to agent runtime. |
+| `max_context_tokens` | int | none (provider default) | Positive integer | Passed as env var to agent runtime. |
 | `thread_replies` | bool | `true` | `true` / `false` | Reply in-thread when the triggering message is in a thread. |
 | `broadcast_replies` | bool | `false` | `true` / `false` | Also surface thread replies to the main channel. |
 
-**Unknown keys** in `defaults` (in `plugin.json`) or in a persona's frontmatter behavioral config
-fields are **validation errors** in `sprout pack validate` — this catches typos like `temprature`
-at validate time. At deploy time, sprout-acp logs a `WARN` and ignores the unknown key, remaining
-fail-soft:
+**Unknown keys** in `defaults` (in `plugin.json`) are **validation warnings** in `sprout pack
+validate` — this catches typos like `temprature` at validate time. Unknown keys in persona
+frontmatter are **hard errors** (via `deny_unknown_fields` in the YAML parser). At deploy time,
+sprout-acp logs a `WARN` and ignores unknown manifest keys, remaining fail-soft:
 
 ```
 WARN: Unknown key "temprature" in defaults (plugin.json); ignoring
@@ -745,27 +737,19 @@ sprout-acp resolves pack defaults and per-persona overrides (precedence levels 3
 effective configuration per persona **before** injecting environment variables into the child
 process. The env vars set reflect the fully-resolved values — not the raw persona frontmatter.
 
-sprout-acp translates persona behavioral config fields to goose configuration via environment
-variables injected into the child process at spawn time (planned — see Section 15):
+sprout-acp translates persona behavioral config fields to agent configuration via environment
+variables injected into the child process at spawn time:
 
 | Persona field | Env var(s) | Notes |
 |---|---|---|
 | `model: "anthropic:claude-sonnet-4-20250514"` | `GOOSE_PROVIDER=anthropic` + `GOOSE_MODEL=claude-sonnet-4-20250514` | Split on first `:` |
-| `temperature: 0.3` | `GOOSE_TEMPERATURE=0.3` | Read via `std::env::var` at model creation |
-| `max_context_tokens: 128000` | `GOOSE_CONTEXT_LIMIT=128000` | Read via `Config::global().get_param()` — cached at first use |
+| `temperature: 0.3` | `GOOSE_TEMPERATURE=0.3` | Read by agent runtime at startup |
+| `max_context_tokens: 128000` | `GOOSE_CONTEXT_LIMIT=128000` | Read by agent runtime at startup |
 
 If `model` is omitted from both the persona frontmatter and `defaults`, sprout-acp does not set
-`GOOSE_PROVIDER` or `GOOSE_MODEL`, and goose uses its configured operator default.
-
-> **Current limitation**: `AcpClient::spawn` (in `crates/sprout-acp/src/acp.rs`) inherits the
-> parent process environment — there are no `.env()` calls. In a multi-persona deployment, env
-> vars set on the parent affect **all** spawned agents. Per-persona model config requires
-> per-subprocess env var injection (planned — see Section 15). Until then, per-persona model
-> configuration is only safe with one sprout-acp process per persona.
->
-> **Alternative**: goose-acp implements `session/set_model` (`on_set_model()` in `server.rs`,
-> ACP unstable). This could set the model per-session after `session/new` without env var
-> injection — but only covers model, not provider/temperature/context. See open question 2.
+`GOOSE_PROVIDER` or `GOOSE_MODEL`, and the agent runtime uses its configured operator default.
+
+> **Implementation note**: `AcpClient::spawn` accepts per-persona env vars via the `extra_env` parameter. sprout-acp checks `std::env::var(key)` before injecting each var — if the parent environment already has the key set, injection is skipped (operator precedence, level 1).
 
 See the Canonical Behavioral Config Field Schema table above for the full field reference.
 
@@ -775,7 +759,7 @@ See the Canonical Behavioral Config Field Schema table above for the full field
 > level 5). The built-in defaults only apply when neither the persona nor the pack defaults specify
 > a value.
 
-All fields are consumed entirely by sprout-acp. None are passed to goose directly.
+All fields are consumed entirely by sprout-acp. None are passed to the agent runtime directly — they are projected as env vars or used by the harness's subscription/dispatch logic.
 
 ---
 
@@ -844,6 +828,21 @@ with OPS registries. Details TBD.
 Installed packs live at `~/.sprout/packs/<pack-id>/`. sprout-acp reads packs from this location
 at agent startup.
 
+### Desktop App Import
+
+The Sprout desktop app can import persona packs via the Import button:
+
+- **My Agents → Import**: Accepts `.persona.md` files (individual personas) or `.zip` files
+  (persona packs detected by `.plugin/plugin.json`). Pack zips are resolved in a temp directory;
+  each persona is previewed and imported individually into the persona library.
+- **My Teams → Import**: Accepts `.zip` files (persona packs). The pack name becomes the team
+  name; each persona becomes a team member.
+
+> **Note**: The Import button parses and previews personas from the pack — it does not install the
+> pack directory itself. For full pack installation (which copies the pack to
+> `<AppDataDir>/agents/packs/<pack-id>/` with re-validation), use the `install_persona_pack`
+> Tauri command or a future "Install Pack" UI button.
+
 ---
 
 ## 11. Delivery Mechanism Summary
@@ -852,27 +851,27 @@ How each pack component reaches the running agent:
 
 | Component | Delivery Method | Mechanism | Filesystem Write? |
 |-----------|----------------|-----------|-------------------|
-| Skills | Copy at deploy time | sprout-acp copies `skills/` → `$AGENT_CWD/.agents/skills/` | ✅ Yes (only one) |
+| Skills | Copy at deploy time (planned) | sprout-acp will copy `skills/` → `$AGENT_CWD/.agents/skills/` | ✅ Yes (only one) |
 | MCP servers | ACP protocol | `NewSessionRequest.mcp_servers` | ❌ No |
 | Persona prompt | User message prefix | `[System]` block prepended to user message text by sprout-acp | ❌ No |
 | Pack instructions | User message prefix | Appended to `[System]` block in user message text | ❌ No |
 | Lifecycle hooks | Harness internal | sprout-acp fires shell commands directly | ❌ No |
-| Model/provider | Child process env vars | `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT` (planned — see Section 15) | ❌ No |
+| Model/provider | Child process env vars | Agent-runtime-specific env vars (e.g. `GOOSE_PROVIDER`, `GOOSE_MODEL`) | ❌ No |
 | Behavioral config | Harness internal | sprout-acp subscription + dispatch logic | ❌ No |
-| Pack defaults (`defaults`) | Harness internal | Resolved at deploy time by sprout-acp into per-persona effective config; never passed to goose directly | ❌ No |
+| Pack defaults (`defaults`) | Harness internal | Resolved at deploy time by sprout-acp into per-persona effective config; never passed to the agent runtime directly | ❌ No |
 
 > **Pack defaults are resolved at deploy time**, not at runtime. When sprout-acp loads a pack and
 > constructs per-persona session configurations, it merges the `defaults` object with each persona's
 > frontmatter behavioral config fields (per the precedence model in Section 9) and stores the
-> resulting effective configuration. The `defaults` object itself is not forwarded to goose or
+> resulting effective configuration. The `defaults` object itself is not forwarded to the agent runtime or
 > stored in any runtime artifact — only the resolved per-persona values are used.
 
 ### The `[System]` Block — Current Implementation
 
 sprout-acp's `format_prompt()` in `queue.rs` prepends a `[System]` block to the **user message
-text** before sending it to goose. This is a **sprout-acp feature, not a goose feature**. Goose
-sees the `[System]` prefix as part of the user message content — it is NOT injected into goose's
-actual system prompt.
+text** before sending it to the agent runtime. This is a **sprout-acp feature, not an agent runtime
+feature**. The agent sees the `[System]` prefix as part of the user message content — it is NOT
+injected into the agent's actual system prompt.
 
 For persona-backed agents, the `[System]` block contains:
 
@@ -902,12 +901,12 @@ The `[System]` prefix re-sends the full persona prompt on every turn. True syste
 | `goose acp --skill-path ./skills` | `--skill-path` flag does not exist in goose |
 | `goose acp --rules-dir ./rules` | `--rules-dir` flag does not exist in goose |
 | `goose acp --system-prompt-file ./prompt.md` | Flag does not exist in goose-acp |
-| `rules/*.mdc` files | Goose does not read `.mdc` files |
-| `skills/` at pack root (without copying) | Goose scans `.agents/skills/`, not `skills/` |
-| Hooks in goose config | Goose has no hook system |
-| SSE transport in `.mcp.json` | goose-acp rejects SSE; use stdio or streamable_http |
+| `rules/*.mdc` files | Agent runtimes typically don't read `.mdc` files |
+| `skills/` at pack root (without copying) | Agent runtimes scan `.agents/skills/`, not `skills/` |
+| Hooks in goose config | Agent runtimes have no hook system; hooks are a harness feature |
+| SSE transport in `.mcp.json` | ACP runtime rejects SSE; use stdio or streamable_http |
 | SKILL.md without `name:` or `description:` | Skill silently skipped; no fallback |
-| Setting `GOOSE_MODEL` on parent process (multi-persona) | Affects all agents; use per-subprocess injection (planned) |
+| Setting `GOOSE_MODEL` on parent process (multi-persona) | Affects all agents; use per-subprocess injection via `extra_env` |
 | Expecting `defaults` sub-key inheritance | No deep merge; object/array fields replaced entirely |
 
 ---
@@ -916,9 +915,11 @@ The `[System]` prefix re-sends the full persona prompt on every turn. True syste
 
 ### Secret Management
 
-Never embed secrets in pack files. Use `${VAR_NAME}` references in all `env` blocks. sprout-acp
-resolves them from the process environment at startup and refuses to start if any are unresolved.
-Inject secrets via your deployment mechanism (systemd env files, Vault, Kubernetes secrets, etc.).
+Never embed secrets in pack files. Use `${VAR_NAME}` references in all `env` blocks. Currently,
+`${VAR_NAME}` strings are passed through as literals to the agent runtime (see Section 6). When
+harness-side interpolation is implemented, sprout-acp will resolve them from the process
+environment at startup and refuse to start if any are unresolved. Inject secrets via your
+deployment mechanism (systemd env files, Vault, Kubernetes secrets, etc.).
 
 ### Pack Integrity
 
@@ -1017,9 +1018,7 @@ Field mapping from flat JSON (`personas/lep.json`) to `.persona.md`:
 
 ### Backward Compatibility
 
-sprout-acp supports both V6 (namespaced `sprout:` block) and the current (flat top-level fields)
-formats during the transition period. The V6 namespaced format is deprecated and will be removed
-in the next major version.
+The V6 namespaced `sprout:` block format is not supported. Only the current flat top-level fields format is accepted. The `respond_to` key is accepted as a legacy alias for `triggers` in both persona frontmatter and `plugin.json` defaults.
 
 ---
 
@@ -1027,13 +1026,13 @@ in the next major version.
 
 ### Unresolved
 
-1. **`session/set_model` as env var alternative**: goose-acp implements `on_set_model()` (ACP
+1. **`session/set_model` as env var alternative**: The ACP runtime implements `on_set_model()` (ACP
    unstable feature). sprout-acp could call `session/set_model` after `session/new` to set the
    model per-session without env var injection. This avoids the `AcpClient::spawn` limitation for
    model (but not provider, temperature, or context limit). Deferred pending stability of the ACP
    unstable feature.
 
-2. **`CONTEXT_FILE_NAMES` env var**: Goose supports this env var to control which filenames are
+2. **`CONTEXT_FILE_NAMES` env var**: The goose agent runtime supports this env var to control which filenames are
    scanned for hints. Should sprout-acp set this to include pack-specific filenames? Deferred
    pending use case.
 
@@ -1059,12 +1058,12 @@ Features required by this spec but not yet implemented.
 
 | ID | What | Where |
 |----|------|-------|
-| PF-1 | True system prompt injection: call `agent.extend_system_prompt()` after `create_agent_for_session()` in `on_new_session()`. Current `[System]` prefix re-sends persona prompt on every turn; true injection fires once at session creation. | `goose/src/agents/agent.rs` + `goose-acp/src/server.rs` `on_new_session()` |
-| PF-2 | `sprout pack validate` CLI: schema-validate `plugin.json` (including `defaults` — unknown keys are errors, type mismatches are errors); check `.persona.md` required identity fields; validate per-persona behavioral config fields against the canonical schema; verify all `skills:` and `hooks:` paths exist; error on `SKILL.md` missing `name:` or `description:`; warn when `name:` differs from directory name. | `sprout-cli` / `sprout-admin` |
+| PF-1 | True system prompt injection via the ACP protocol's `on_new_session()`. Current `[System]` prefix re-sends persona prompt on every turn; true injection fires once at session creation. | ACP server `on_new_session()` |
+| PF-2 | `sprout pack validate` CLI: **Implemented.** Schema-validates `plugin.json`; checks `.persona.md` required identity fields; validates behavioral config fields; warns on unknown keys and skill name mismatches. Remaining: verify `skills:` and `hooks:` paths exist; error on `SKILL.md` missing `name:` or `description:`. | `sprout-cli` / `sprout-admin` |
 | PF-3 | Skill collision warning: emit `WARN` when a pack skill is skipped because a skill with the same load key already exists in `.agents/skills/`. | sprout-acp skill copy logic |
 | PF-4 | `$AGENT_CWD` resolution: determine `NewSessionRequest.cwd` from (1) `AGENT_CWD` env var, (2) `std::env::current_dir()`, (3) error and refuse to start. | sprout-acp startup / session init |
-| PF-5 | Skill parse failure warning: emit `WARN` when `parse_skill_content` returns `None` (missing `name:`, missing `description:`, or malformed frontmatter). Currently goose silently skips. Until fixed in goose, sprout-acp should pre-validate during skill copy. | `goose/src/agents/platform_extensions/summon.rs` `parse_skill_content()` |
-| PF-6 | Per-subprocess env var injection: extend `AcpClient::spawn` to accept `Vec<(String, String)>` injected via `Command::env()`. Required so `GOOSE_PROVIDER`, `GOOSE_MODEL`, `GOOSE_TEMPERATURE`, and `GOOSE_CONTEXT_LIMIT` can differ per persona without affecting all agents. sprout-acp must check `std::env::var(key)` before injecting — if the parent environment already has the key set, skip injection (operator env vars take precedence, level 1). | `sprout-acp/src/acp.rs` `AcpClient::spawn()` |
+| PF-5 | Skill parse failure warning: emit `WARN` when `parse_skill_content` returns `None` (missing `name:`, missing `description:`, or malformed frontmatter). Currently the agent runtime silently skips. sprout-acp should pre-validate during skill copy. | sprout-acp skill copy logic |
+| PF-6 | Per-subprocess env var injection: **Implemented.** `AcpClient::spawn` accepts `extra_env: &[(String, String)]` injected via `Command::env()`. sprout-acp checks `std::env::var(key)` before injecting — operator env vars take precedence (level 1). | `sprout-acp/src/acp.rs` `AcpClient::spawn()` |
 
 ---
 
