Skip to content

feat: Markdown-based persona packs (crate + ACP + desktop)#297

Merged
tlongwell-block merged 29 commits intomainfrom
tyler/json-to-md-persona-migration
Apr 11, 2026
Merged

feat: Markdown-based persona packs (crate + ACP + desktop)#297
tlongwell-block merged 29 commits intomainfrom
tyler/json-to-md-persona-migration

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Apr 11, 2026

Summary

Introduces .persona.md — a markdown-based persona format that replaces flat JSON persona records with composable, portable persona packs. This is a superset of the Open Plugin Spec — every persona pack is a valid OPS package.

What Changed

New crate: sprout-persona (~4,100 lines, 175 tests)

  • persona.rs.persona.md parser (YAML frontmatter + markdown body)
  • manifest.rsplugin.json manifest parser (OPS-compatible)
  • merge.rs — 5-level precedence resolution (operator > UI > persona > pack defaults > built-in)
  • resolve.rsResolvedPack API producing typed, ACP-ready output with semantic validation
  • pack.rs — Pack loader with path traversal defense and bounded file reads
  • validate.rs — Pack validation (zero-persona, duplicate names, name charset, triggers type checks)
  • legacy.rs — JSON adapter + migration tool

CLI (sprout-cli)

  • sprout pack validate <dir> — validates a pack directory, reports errors/warnings
  • sprout pack inspect <dir> — shows fully resolved effective config (post-merge, model split, env vars)

ACP harness (sprout-acp)

  • --persona-pack <dir> + --persona-name <name> CLI flags
  • Config::from_cli() resolves persona and applies precedence (CLI/env > persona > built-in)
  • Per-persona env var injection at spawn (GOOSE_PROVIDER, GOOSE_MODEL, GOOSE_TEMPERATURE, GOOSE_CONTEXT_LIMIT)
  • Operator-precedence env var safety — operator-set vars always win over pack values

Desktop app

  • Import support: Import button accepts .persona.md, .persona.json, .persona.png, and .zip files
  • Pack zip import: Zips containing .plugin/plugin.json are detected as persona packs and resolved via resolve_pack() — works from both the Personas and Teams import buttons
  • Editable pack personas: User edits to system_prompt and model override pack defaults at runtime via ACP's precedence model (CLI/env > persona > default). Runtime reads live PersonaRecord, not stale creation-time snapshot.
  • Pack import/uninstall/list with security hardening (pack-id sanitization, enclosed_name() zip extraction, re-validation after copy, decompressed size limits)
  • Uninstall blocked if managed agents still reference the pack
  • Pack personas protected from individual deletion ("Managed by pack" hint — use Uninstall Pack)
  • PersonaRecord extended with source_pack and source_pack_persona_slug
  • ManagedAgentRecord extended with persona_pack_path and persona_name_in_pack
  • TypeScript types updated

Example pack (examples/meadow-core/)

  • Minimal working 3-agent team: Skip (orchestrator), Lev (security reviewer), Bana (architecture reviewer)
  • Includes shared github-research skill, pack-level instructions, and pack defaults
  • Validates clean with sprout pack validate

Architecture

  .persona.md pack on disk
         │
         ▼
  ┌─────────────────────┐
  │  sprout-persona      │   resolve_pack(dir) → ResolvedPack
  │  (parse → merge →    │   One ResolvedPersona per persona
  │   resolve → project) │   Maps 1:1 to ACP Config fields
  └──────────┬──────────┘
             │
     ┌───────┴───────┐
     ▼               ▼
  Desktop           ACP
  imports pack,     --persona-pack + --persona-name
  creates agents,   reads one ResolvedPersona,
  spawns N ACP      maps to Config,
  processes         runs single agent

Execution model: One persona per ACP process. Desktop spawns N processes for N personas in a pack.

Persona Format Example

---
name: lev
display_name: Lev
description: Security analyst for the team.
model: anthropic:claude-sonnet-4-20250514
temperature: 0.3
subscribe:
  - "#security-reviews"
triggers:
  mentions: true
  keywords: ["security", "CVE"]
thread_replies: true
---
You are Lev, a security analyst. You review code for vulnerabilities...

Security

  • Operator-precedence env vars: Pack-projected env vars are only injected if the operator hasn't already set them — operator values always win. No agent-specific allowlist; Sprout is agent-agnostic
  • Pack-id sanitization: [a-zA-Z0-9._-]+, no leading dots (prevents .. traversal)
  • Zip extraction: enclosed_name() from zip crate for path safety; decompressed size limits (100MB)
  • Re-validation: Installed pack re-validated after copy with cleanup on failure
  • Hooks stored as raw strings: No path resolution until execution is wired (future PR)
  • Operator precedence at two layers: ACP config + spawn-time env check
  • Semantic validation in resolve_pack(): Zero-persona, duplicate names, and invalid slugs rejected at resolution time
  • Pack personas: individually non-deletable (use Uninstall Pack); editable (user edits override pack defaults via precedence)

Review Scores

Reviewer Score Verdict
Architecture (Hana) APPROVE
Security (Lep) 9/10 APPROVE
Code Quality (Clove) 9.5/10 APPROVE
Crossfire: Opus 7/10 → 9/10 APPROVE (after fixes)
Crossfire: Codex CLI 6/10 → 9/10 APPROVE (after fixes)
Import feature: Codex CLI 4/10 → 6/10 Iterated (path safety, size limits, scope alignment)

Spec

See crates/sprout-persona/PERSONA_PACK_SPEC.md for the full format specification.

Deferred to Follow-Up

  • MCP persona tools
  • MCP server wiring in ACP (field populated, not consumed)
  • Hook execution
  • Skill copying to agent working directories
  • engines.sprout version enforcement
  • Pack distribution/registry
  • sprout pack init scaffolding CLI

…ests (S2)

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.
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
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.
- 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.
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.
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.
…n, runtime bridge

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.
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.
…ookup

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.
- #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.
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
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.
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
…PERSONA_PACK_SPEC.md

- 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
…es in spec

- 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
…ck.rs cleanup

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.
@tlongwell-block tlongwell-block force-pushed the tyler/json-to-md-persona-migration branch from 5fb4e89 to d8d9bdc Compare April 11, 2026 14:14
…ona-migration

* origin/main:
  feat(desktop): add Pulse social notes surface (#296)
  Fix flaky desktop smoke tests (#294)
  Add agent lifecycle controls to channel members sidebar (#291)
  Update nest_agents.md tagging info (#292)
  feat: add Sprout nest — persistent agent workspace at ~/.sprout (#290)
  Fix auth and SSRF vulns (#261)
  Add per-agent MCP toolset configuration to agent setup (#279)
  feat(desktop): team & persona import/edit flows (#288)
  Remove menu item subtitles and fix persona card overflow (#289)
  feat: Phase 1 video upload support (Blossom-compliant-ish) (#285)
  Add inline subtitles to menu items and field descriptions (#276)
  Improve ephemeral channel affordances and hide archived sidebar rows (#286)
  Fix @mention search to use word-boundary prefix matching (#278)
  Allow bot owners to remove their agents from any channel (#284)
  [codex] Polish agent selectors and settings layout (#283)

# Conflicts:
#	desktop/scripts/check-file-sizes.mjs
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.
…defaults

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
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.
@tlongwell-block tlongwell-block force-pushed the tyler/json-to-md-persona-migration branch from 51d5597 to 523592e Compare April 11, 2026 14:56
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
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).
… detection

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.
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.
…atus

- 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)
@tlongwell-block tlongwell-block merged commit d07385f into main Apr 11, 2026
9 checks passed
@tlongwell-block tlongwell-block deleted the tyler/json-to-md-persona-migration branch April 11, 2026 17:32
tellaho added a commit that referenced this pull request Apr 14, 2026
* origin/main:
  Replace inline channel creation with dialog (#312)
  chore: improve chat message layout to left-aligned design (#309)
  Add edit dialog for managed agents with relay profile sync (#277)
  fix(ci): build relay with optimized profile to fix flaky e2e tests (#307)
  Update actions/checkout action to v6 (#305)
  Update dependency @tanstack/react-query to v5.98.0 (#304)
  Update dependency @playwright/test to v1.59.1 (#303)
  Update react monorepo to v19.2.5 (#302)
  feat(mobile): scaffold Flutter app with Riverpod & Catppuccin theme (#306)
  Update dependency @tanstack/react-router to v1.168.13 (#301)
  feat: Markdown-based persona packs (crate + ACP + desktop) (#297)
  feat(desktop): improve Agents page UX (#298)
  feat(desktop): add Pulse social notes surface (#296)
  Fix flaky desktop smoke tests (#294)
  Add agent lifecycle controls to channel members sidebar (#291)

# Conflicts:
#	desktop/pnpm-lock.yaml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant