Skip to content

Commit bd6ff57

Browse files
BunsDevclaude
andcommitted
feat(familiars): add access tier so build-tier familiars can use git/shell
Adds an `access` field to `CovenFamiliar` (`~/.coven/familiars.toml`) and wires Coven familiars into the existing agent-mode pipeline so the familiar's access tier (`full`/`read-only`/`search-only`) controls which tools the familiar can invoke when selected via `--agent <id>` or the `/agents` picker. Default is `read-only` — write/exec is opt-in per familiar. Changes: - `coven_shared::CovenFamiliar` gains `access: Option<String>` with a `resolved_access()` accessor that falls back to `DEFAULT_FAMILIAR_ACCESS` (`"read-only"`). - New `familiar_to_agent_definition()` and `default_agents_with_familiars()` helpers convert each familiar to an `AgentDefinition` and merge them with the built-in agents (built-ins win on collision). - CLI agent merge (headless `--agent` flow and interactive agent-mode switch) now uses `default_agents_with_familiars()` so familiars take part in tool filtering exactly like `build`/`plan`/`explore`. - `/agents` picker: selecting a familiar from the list (or its detail view) now activates it as the session's agent mode and closes the menu; user-defined agents continue to open the editor. Tests: new unit coverage in `coven_shared` for `resolved_access()`, `familiar_to_agent_definition()`, and the merged-agents helper; new unit tests in `agents_view` for `familiar_id_from_source` and the `confirm_selection` return paths (familiar vs user agent). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe8f9b1 commit bd6ff57

5 files changed

Lines changed: 474 additions & 125 deletions

File tree

docs/familiars.md

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,25 @@ display_name = "Dev"
129129
emoji = "🤖"
130130
role = "Code Agent"
131131
description = "Fast, focused code implementation and review."
132-
pronounces = "they/them"
132+
pronouns = "they/them"
133+
access = "full"
133134

134135
[[familiar]]
135136
id = "research"
136137
display_name = "Research"
137138
emoji = "🧙"
138139
role = "Research & Reasoning"
139140
description = "Deep research, synthesis, and structured thinking."
141+
# access omitted → defaults to "read-only"
140142

141143
[[familiar]]
142144
id = "writer"
143145
display_name = "Writer"
144146
emoji = "✍️"
145147
role = "Writing & Communication"
146148
description = "Clear writing, docs, and async communication."
147-
pronounces = "she/her"
149+
pronouns = "she/her"
150+
access = "read-only"
148151
```
149152

150153
### Fields
@@ -157,6 +160,56 @@ pronounces = "she/her"
157160
| `role` | | Short role label — shown in the detail view and persona prefix. |
158161
| `description` | | Full description used to build the persona system prompt. |
159162
| `pronouns` | | Appended to the persona prompt if present. |
163+
| `access` | | Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`. Defaults to `"read-only"` when omitted. See [Tool access tiers](#tool-access-tiers) below. |
164+
165+
---
166+
167+
## Tool access tiers
168+
169+
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.
170+
171+
| Tier | What the familiar can do | Typical role |
172+
|---|---|---|
173+
| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` |
174+
| `read-only` | Read & search the workspace, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` |
175+
| `search-only` | Web/search lookups only — no filesystem access | Pure-research personas with no codebase context |
176+
177+
### Why the default is restrictive
178+
179+
`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.
180+
181+
### Recommended defaults per role
182+
183+
| Role | Suggested `access` |
184+
|---|---|
185+
| Code / Build / Ship | `"full"` |
186+
| General Helper / Assistant | `"full"` (set if you want them to edit/run; otherwise leave to default) |
187+
| Orchestrator / Queen | `"full"` (they coordinate work that requires writes) |
188+
| Research / Synthesis | `"read-only"` (default — keep them honest) |
189+
| Strategy / Navigation | `"read-only"` (default) |
190+
| Memory / Reflection | `"read-only"` (default) |
191+
| Comms / Social | `"read-only"` (default) |
192+
193+
### Example: minimal opt-in roster
194+
195+
```toml
196+
# Build-tier — can edit and run.
197+
[[familiar]]
198+
id = "cody"
199+
display_name = "Cody"
200+
role = "Code"
201+
access = "full"
202+
203+
# Research-tier — read-only by default (no `access` line needed).
204+
[[familiar]]
205+
id = "sage"
206+
display_name = "Sage"
207+
role = "Research"
208+
```
209+
210+
### How `access` interacts with `settings.json` agents
211+
212+
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).
160213

161214
---
162215

src-rust/crates/cli/src/main.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -766,10 +766,11 @@ async fn main() -> anyhow::Result<()> {
766766
query_config.provider_registry = Some(provider_registry.clone());
767767

768768
// Wire in the named agent (--agent flag).
769-
// Merge built-in default agents with user-defined agents (user wins on collision).
769+
// Merge built-in default agents + Coven familiars with user-defined agents.
770+
// Order: built-ins → familiars (built-ins win) → settings.json agents (user wins).
770771
let tools = if let Some(ref agent_name) = cli.agent {
771772
query_config.agent_name = Some(agent_name.clone());
772-
let mut all_agents = claurst_core::default_agents();
773+
let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars();
773774
all_agents.extend(config.agents.clone());
774775
if let Some(def) = all_agents.get(agent_name) {
775776
let access = def.access.clone();
@@ -2806,11 +2807,12 @@ async fn run_interactive(
28062807
if !app.model_name.is_empty() {
28072808
session.model = app.model_name.clone();
28082809
}
2809-
// Handle agent mode change (Tab key cycles build→plan→explore)
2810+
// Handle agent mode change (Tab key cycles build→plan→explore;
2811+
// /agents picker can also select a Coven familiar).
28102812
if app.agent_mode_changed {
28112813
app.agent_mode_changed = false;
28122814
let mode = app.agent_mode.as_deref().unwrap_or("build");
2813-
let mut all_agents = claurst_core::default_agents();
2815+
let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars();
28142816
all_agents.extend(cmd_ctx.config.agents.clone());
28152817
if let Some(def) = all_agents.get(mode) {
28162818
base_query_config.agent_name = Some(mode.to_string());

src-rust/crates/core/src/coven_shared.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::
3939
// Familiars (~/.coven/familiars.toml)
4040
// ---------------------------------------------------------------------------
4141

42+
/// Default tool-access tier applied when a familiar omits the `access` field.
43+
///
44+
/// Intentionally restrictive — write/exec power is opt-in by setting
45+
/// `access = "full"` per familiar in `~/.coven/familiars.toml`.
46+
pub const DEFAULT_FAMILIAR_ACCESS: &str = "read-only";
47+
4248
/// One entry in `~/.coven/familiars.toml`.
4349
///
4450
/// Schema mirrors what the daemon serves at `GET /api/v1/familiars`.
@@ -55,6 +61,17 @@ pub struct CovenFamiliar {
5561
pub description: Option<String>,
5662
#[serde(default)]
5763
pub pronouns: Option<String>,
64+
/// Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`.
65+
/// Absent → [`DEFAULT_FAMILIAR_ACCESS`] (`"read-only"`).
66+
#[serde(default)]
67+
pub access: Option<String>,
68+
}
69+
70+
impl CovenFamiliar {
71+
/// Resolved access tier — the explicit value or [`DEFAULT_FAMILIAR_ACCESS`].
72+
pub fn resolved_access(&self) -> &str {
73+
self.access.as_deref().unwrap_or(DEFAULT_FAMILIAR_ACCESS)
74+
}
5875
}
5976

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

96+
/// Build a [`crate::config::AgentDefinition`] from a familiar so it can be
97+
/// selected through the same `--agent` / agent-mode plumbing as built-in
98+
/// agents. Returns `(id, def)` keyed on the familiar's lowercase id.
99+
///
100+
/// The familiar's `access` tier flows into [`crate::config::AgentDefinition::access`]
101+
/// so the existing tool-filter pipeline in the CLI is the single source of
102+
/// truth for what tools a familiar can use.
103+
pub fn familiar_to_agent_definition(
104+
fam: &CovenFamiliar,
105+
) -> (String, crate::config::AgentDefinition) {
106+
let display = fam.display_name.as_deref().unwrap_or(&fam.id).to_string();
107+
let emoji = fam.emoji.as_deref().unwrap_or("✨");
108+
let role = fam.role.as_deref().unwrap_or("Familiar");
109+
let desc_body = fam
110+
.description
111+
.as_deref()
112+
.unwrap_or("A Coven familiar persona.")
113+
.to_string();
114+
let pronouns = fam
115+
.pronouns
116+
.as_deref()
117+
.map(|p| format!(" Pronouns: {p}."))
118+
.unwrap_or_default();
119+
120+
let prompt = format!(
121+
"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."
122+
);
123+
124+
let def = crate::config::AgentDefinition {
125+
description: Some(format!("{emoji} {role} — {desc_body}")),
126+
model: None,
127+
temperature: None,
128+
prompt: Some(prompt),
129+
access: fam.resolved_access().to_string(),
130+
visible: true,
131+
max_turns: None,
132+
color: None,
133+
};
134+
(fam.id.to_lowercase(), def)
135+
}
136+
137+
/// Return the merged built-in + familiar agent map.
138+
///
139+
/// Built-in agents win on id collision (familiars share lowercase keyspace
140+
/// with `build`/`plan`/`explore`, so collisions are unexpected — but the
141+
/// rule keeps `build` etc. inviolate). Callers extend with user-defined
142+
/// `config.agents` afterwards so user overrides still win.
143+
pub fn default_agents_with_familiars(
144+
) -> std::collections::HashMap<String, crate::config::AgentDefinition> {
145+
let mut map = crate::config::default_agents();
146+
if let Some(fams) = load_familiars() {
147+
for fam in &fams {
148+
let (id, def) = familiar_to_agent_definition(fam);
149+
map.entry(id).or_insert(def);
150+
}
151+
}
152+
map
153+
}
154+
79155
// ---------------------------------------------------------------------------
80156
// Skills (~/.coven/skills/<id>/metadata.json)
81157
// ---------------------------------------------------------------------------
@@ -220,6 +296,115 @@ role = "General Helper"
220296
assert!(load_familiars().is_none());
221297
}
222298

299+
#[test]
300+
fn familiar_access_defaults_to_read_only_when_absent() {
301+
let _g = with_coven_home(|home| {
302+
fs::write(
303+
home.join("familiars.toml"),
304+
r#"
305+
[[familiar]]
306+
id = "sage"
307+
display_name = "Sage"
308+
role = "Research"
309+
"#,
310+
)
311+
.unwrap();
312+
});
313+
let familiars = load_familiars().expect("should parse");
314+
assert!(familiars[0].access.is_none());
315+
assert_eq!(familiars[0].resolved_access(), DEFAULT_FAMILIAR_ACCESS);
316+
assert_eq!(familiars[0].resolved_access(), "read-only");
317+
}
318+
319+
#[test]
320+
fn familiar_access_parses_explicit_tiers() {
321+
let _g = with_coven_home(|home| {
322+
fs::write(
323+
home.join("familiars.toml"),
324+
r#"
325+
[[familiar]]
326+
id = "cody"
327+
access = "full"
328+
329+
[[familiar]]
330+
id = "sage"
331+
access = "read-only"
332+
333+
[[familiar]]
334+
id = "scout"
335+
access = "search-only"
336+
"#,
337+
)
338+
.unwrap();
339+
});
340+
let familiars = load_familiars().expect("should parse");
341+
assert_eq!(familiars[0].resolved_access(), "full");
342+
assert_eq!(familiars[1].resolved_access(), "read-only");
343+
assert_eq!(familiars[2].resolved_access(), "search-only");
344+
}
345+
346+
#[test]
347+
fn familiar_to_agent_definition_threads_access_tier() {
348+
let fam = CovenFamiliar {
349+
id: "Cody".to_string(),
350+
display_name: Some("Cody".to_string()),
351+
emoji: Some("⚡".to_string()),
352+
role: Some("Code".to_string()),
353+
description: Some("Builds and ships.".to_string()),
354+
pronouns: None,
355+
access: Some("full".to_string()),
356+
};
357+
let (id, def) = familiar_to_agent_definition(&fam);
358+
assert_eq!(id, "cody", "id should be lowercased for map keys");
359+
assert_eq!(def.access, "full");
360+
assert!(def.visible);
361+
let prompt = def.prompt.as_deref().unwrap_or("");
362+
assert!(prompt.contains("Cody"));
363+
assert!(prompt.contains("Code"));
364+
}
365+
366+
#[test]
367+
fn familiar_to_agent_definition_defaults_to_read_only() {
368+
let fam = CovenFamiliar {
369+
id: "sage".to_string(),
370+
display_name: None,
371+
emoji: None,
372+
role: None,
373+
description: None,
374+
pronouns: None,
375+
access: None,
376+
};
377+
let (_id, def) = familiar_to_agent_definition(&fam);
378+
assert_eq!(def.access, "read-only");
379+
}
380+
381+
#[test]
382+
fn default_agents_with_familiars_merges_without_clobbering_builtins() {
383+
let _g = with_coven_home(|home| {
384+
fs::write(
385+
home.join("familiars.toml"),
386+
r#"
387+
[[familiar]]
388+
id = "cody"
389+
display_name = "Cody"
390+
role = "Code"
391+
access = "full"
392+
393+
[[familiar]]
394+
id = "build" # collides with built-in; built-in must win
395+
display_name = "Imposter"
396+
access = "search-only"
397+
"#,
398+
)
399+
.unwrap();
400+
});
401+
let merged = default_agents_with_familiars();
402+
// Built-in `build` is untouched.
403+
assert_eq!(merged.get("build").map(|d| d.access.as_str()), Some("full"));
404+
// Familiar `cody` was merged in with its declared access.
405+
assert_eq!(merged.get("cody").map(|d| d.access.as_str()), Some("full"));
406+
}
407+
223408
#[test]
224409
fn list_daemon_skills_scans_metadata_files() {
225410
let _g = with_coven_home(|home| {

0 commit comments

Comments
 (0)