Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions docs/familiars.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,25 @@ display_name = "Dev"
emoji = "🤖"
role = "Code Agent"
description = "Fast, focused code implementation and review."
pronounces = "they/them"
pronouns = "they/them"
access = "full"

[[familiar]]
id = "research"
display_name = "Research"
emoji = "🧙"
role = "Research & Reasoning"
description = "Deep research, synthesis, and structured thinking."
# access omitted → defaults to "read-only"

[[familiar]]
id = "writer"
display_name = "Writer"
emoji = "✍️"
role = "Writing & Communication"
description = "Clear writing, docs, and async communication."
pronounces = "she/her"
pronouns = "she/her"
access = "read-only"
```

### Fields
Expand All @@ -157,6 +160,56 @@ pronounces = "she/her"
| `role` | | Short role label — shown in the detail view and persona prefix. |
| `description` | | Full description used to build the persona system prompt. |
| `pronouns` | | Appended to the persona prompt if present. |
| `access` | | Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`. Defaults to `"read-only"` when omitted. See [Tool access tiers](#tool-access-tiers) below. |

---

## Tool access tiers

The `access` field controls **which tools** a familiar may invoke once you select them as the active agent (via `--agent <id>` or the `/agents` picker). The same tool-filter pipeline used for the built-in `build` / `plan` / `explore` modes applies, so the rules are consistent across the product.

| Tier | What the familiar can do | Typical role |
|---|---|---|
| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` |
| `read-only` | Read & search the workspace, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` |
| `search-only` | Web/search lookups only — no filesystem access | Pure-research personas with no codebase context |

### Why the default is restrictive

`access` defaults to `read-only`. Granting write/exec power is **opt-in**: you must set `access = "full"` explicitly on a familiar to let it edit files or run shell commands. This avoids surprise when a freshly-defined familiar (perhaps written for a research role) accidentally gains the ability to mutate the workspace.

### Recommended defaults per role

| Role | Suggested `access` |
|---|---|
| Code / Build / Ship | `"full"` |
| General Helper / Assistant | `"full"` (set if you want them to edit/run; otherwise leave to default) |
| Orchestrator / Queen | `"full"` (they coordinate work that requires writes) |
| Research / Synthesis | `"read-only"` (default — keep them honest) |
| Strategy / Navigation | `"read-only"` (default) |
| Memory / Reflection | `"read-only"` (default) |
| Comms / Social | `"read-only"` (default) |

### Example: minimal opt-in roster

```toml
# Build-tier — can edit and run.
[[familiar]]
id = "cody"
display_name = "Cody"
role = "Code"
access = "full"

# Research-tier — read-only by default (no `access` line needed).
[[familiar]]
id = "sage"
display_name = "Sage"
role = "Research"
```

### How `access` interacts with `settings.json` agents

User-defined agents in `.coven-code/agents/*.md` or `settings.json` continue to win on id collisions. Familiars are merged after the built-in `build` / `plan` / `explore` agents, before any user-defined agents — so a workspace override of the same name shadows the familiar entirely (including its `access` value).

---

Expand Down
10 changes: 6 additions & 4 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,10 +766,11 @@ async fn main() -> anyhow::Result<()> {
query_config.provider_registry = Some(provider_registry.clone());

// Wire in the named agent (--agent flag).
// Merge built-in default agents with user-defined agents (user wins on collision).
// Merge built-in default agents + Coven familiars with user-defined agents.
// Order: built-ins → familiars (built-ins win) → settings.json agents (user wins).
let tools = if let Some(ref agent_name) = cli.agent {
query_config.agent_name = Some(agent_name.clone());
let mut all_agents = claurst_core::default_agents();
let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars();
all_agents.extend(config.agents.clone());
if let Some(def) = all_agents.get(agent_name) {
let access = def.access.clone();
Expand Down Expand Up @@ -2791,11 +2792,12 @@ async fn run_interactive(
if !app.model_name.is_empty() {
session.model = app.model_name.clone();
}
// Handle agent mode change (Tab key cycles build→plan→explore)
// Handle agent mode change (Tab key cycles build→plan→explore;
// /agents picker can also select a Coven familiar).
if app.agent_mode_changed {
app.agent_mode_changed = false;
let mode = app.agent_mode.as_deref().unwrap_or("build");
let mut all_agents = claurst_core::default_agents();
let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars();
all_agents.extend(cmd_ctx.config.agents.clone());
if let Some(def) = all_agents.get(mode) {
base_query_config.agent_name = Some(mode.to_string());
Expand Down
185 changes: 185 additions & 0 deletions src-rust/crates/core/src/coven_shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::
// Familiars (~/.coven/familiars.toml)
// ---------------------------------------------------------------------------

/// Default tool-access tier applied when a familiar omits the `access` field.
///
/// Intentionally restrictive — write/exec power is opt-in by setting
/// `access = "full"` per familiar in `~/.coven/familiars.toml`.
pub const DEFAULT_FAMILIAR_ACCESS: &str = "read-only";

/// One entry in `~/.coven/familiars.toml`.
///
/// Schema mirrors what the daemon serves at `GET /api/v1/familiars`.
Expand All @@ -55,6 +61,17 @@ pub struct CovenFamiliar {
pub description: Option<String>,
#[serde(default)]
pub pronouns: Option<String>,
/// Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`.
/// Absent → [`DEFAULT_FAMILIAR_ACCESS`] (`"read-only"`).
#[serde(default)]
pub access: Option<String>,
}

impl CovenFamiliar {
/// Resolved access tier — the explicit value or [`DEFAULT_FAMILIAR_ACCESS`].
pub fn resolved_access(&self) -> &str {
self.access.as_deref().unwrap_or(DEFAULT_FAMILIAR_ACCESS)
}
}

#[derive(Debug, Deserialize)]
Expand All @@ -76,6 +93,65 @@ pub fn load_familiars() -> Option<Vec<CovenFamiliar>> {
}
}

/// Build a [`crate::config::AgentDefinition`] from a familiar so it can be
/// selected through the same `--agent` / agent-mode plumbing as built-in
/// agents. Returns `(id, def)` keyed on the familiar's lowercase id.
///
/// The familiar's `access` tier flows into [`crate::config::AgentDefinition::access`]
/// so the existing tool-filter pipeline in the CLI is the single source of
/// truth for what tools a familiar can use.
pub fn familiar_to_agent_definition(
fam: &CovenFamiliar,
) -> (String, crate::config::AgentDefinition) {
let display = fam.display_name.as_deref().unwrap_or(&fam.id).to_string();
let emoji = fam.emoji.as_deref().unwrap_or("✨");
let role = fam.role.as_deref().unwrap_or("Familiar");
let desc_body = fam
.description
.as_deref()
.unwrap_or("A Coven familiar persona.")
.to_string();
let pronouns = fam
.pronouns
.as_deref()
.map(|p| format!(" Pronouns: {p}."))
.unwrap_or_default();

let prompt = format!(
"You are {emoji} {display}, a Coven familiar with the role of {role}.{pronouns}\n\n{desc_body}\n\nStay in character and remain focused on the developer's goals."
);

let def = crate::config::AgentDefinition {
description: Some(format!("{emoji} {role} — {desc_body}")),
model: None,
temperature: None,
prompt: Some(prompt),
access: fam.resolved_access().to_string(),
visible: true,
max_turns: None,
color: None,
};
(fam.id.to_lowercase(), def)
}

/// Return the merged built-in + familiar agent map.
///
/// Built-in agents win on id collision (familiars share lowercase keyspace
/// with `build`/`plan`/`explore`, so collisions are unexpected — but the
/// rule keeps `build` etc. inviolate). Callers extend with user-defined
/// `config.agents` afterwards so user overrides still win.
pub fn default_agents_with_familiars(
) -> std::collections::HashMap<String, crate::config::AgentDefinition> {
let mut map = crate::config::default_agents();
if let Some(fams) = load_familiars() {
for fam in &fams {
let (id, def) = familiar_to_agent_definition(fam);
map.entry(id).or_insert(def);
}
}
map
}

// ---------------------------------------------------------------------------
// Skills (~/.coven/skills/<id>/metadata.json)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -220,6 +296,115 @@ role = "General Helper"
assert!(load_familiars().is_none());
}

#[test]
fn familiar_access_defaults_to_read_only_when_absent() {
let _g = with_coven_home(|home| {
fs::write(
home.join("familiars.toml"),
r#"
[[familiar]]
id = "sage"
display_name = "Sage"
role = "Research"
"#,
)
.unwrap();
});
let familiars = load_familiars().expect("should parse");
assert!(familiars[0].access.is_none());
assert_eq!(familiars[0].resolved_access(), DEFAULT_FAMILIAR_ACCESS);
assert_eq!(familiars[0].resolved_access(), "read-only");
}

#[test]
fn familiar_access_parses_explicit_tiers() {
let _g = with_coven_home(|home| {
fs::write(
home.join("familiars.toml"),
r#"
[[familiar]]
id = "cody"
access = "full"

[[familiar]]
id = "sage"
access = "read-only"

[[familiar]]
id = "scout"
access = "search-only"
"#,
)
.unwrap();
});
let familiars = load_familiars().expect("should parse");
assert_eq!(familiars[0].resolved_access(), "full");
assert_eq!(familiars[1].resolved_access(), "read-only");
assert_eq!(familiars[2].resolved_access(), "search-only");
}

#[test]
fn familiar_to_agent_definition_threads_access_tier() {
let fam = CovenFamiliar {
id: "Cody".to_string(),
display_name: Some("Cody".to_string()),
emoji: Some("⚡".to_string()),
role: Some("Code".to_string()),
description: Some("Builds and ships.".to_string()),
pronouns: None,
access: Some("full".to_string()),
};
let (id, def) = familiar_to_agent_definition(&fam);
assert_eq!(id, "cody", "id should be lowercased for map keys");
assert_eq!(def.access, "full");
assert!(def.visible);
let prompt = def.prompt.as_deref().unwrap_or("");
assert!(prompt.contains("Cody"));
assert!(prompt.contains("Code"));
}

#[test]
fn familiar_to_agent_definition_defaults_to_read_only() {
let fam = CovenFamiliar {
id: "sage".to_string(),
display_name: None,
emoji: None,
role: None,
description: None,
pronouns: None,
access: None,
};
let (_id, def) = familiar_to_agent_definition(&fam);
assert_eq!(def.access, "read-only");
}

#[test]
fn default_agents_with_familiars_merges_without_clobbering_builtins() {
let _g = with_coven_home(|home| {
fs::write(
home.join("familiars.toml"),
r#"
[[familiar]]
id = "cody"
display_name = "Cody"
role = "Code"
access = "full"

[[familiar]]
id = "build" # collides with built-in; built-in must win
display_name = "Imposter"
access = "search-only"
"#,
)
.unwrap();
});
let merged = default_agents_with_familiars();
// Built-in `build` is untouched.
assert_eq!(merged.get("build").map(|d| d.access.as_str()), Some("full"));
// Familiar `cody` was merged in with its declared access.
assert_eq!(merged.get("cody").map(|d| d.access.as_str()), Some("full"));
}

#[test]
fn list_daemon_skills_scans_metadata_files() {
let _g = with_coven_home(|home| {
Expand Down
Loading