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
68 changes: 68 additions & 0 deletions md/reference/plugin-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ source.path = "skills"
| `installations` | array of tables | no | Named installation declarations (`[[installations]]`). Hooks reference these by name. See [Installations](#installations). |
| `skills` | array of tables | no | Skill groups (`[[skills]]`). |
| `hooks` | array of tables | no | Hooks (`[[hooks]]`). |
| `predicate` | array of tables | no | Custom predicate definitions (`[[predicate]]`). See [Custom predicates](#predicate). |
| `mcp_servers` | array of tables | no | MCP server registrations (`[[mcp_servers]]`). |

**Note**: Every plugin must reference at least one crate somewhere — at the plugin level, in `[[skills]]` groups, or in `[[mcp_servers]]` entries — via a `crates` list or a `crate(...)` [predicate](./predicates.md). Plugins without any crate targeting will fail validation.
Expand Down Expand Up @@ -444,6 +445,73 @@ echo '{"tool": "Bash", "input": "cargo test"}' | cargo agents hook claude pre-to

You can also use `copilot`, `gemini`, `codex`, or `kiro` as the agent name.

## `[[predicate]]`

Each `[[predicate]]` entry defines a custom predicate function that can be used in `predicates` expressions anywhere a predicate is accepted. Custom predicates extend the built-in predicate language with plugin-specific checks.

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | The predicate name. Must be a valid identifier (`[a-zA-Z][a-zA-Z0-9_]*`) and must not collide with builtins (`crate`, `shell`, `path_exists`, `env`, `not`, `any`, `all`). |
| `command` | string or table | The installation to run. Same shape as hook `command` (a string naming a `[[installations]]` entry or an inline table). |
| `args` | array of strings | Optional. Static arguments passed to the command before the dynamic argument. |

### How custom predicates work

Custom predicates are registered globally — a predicate defined in one plugin can be used by any other plugin's `predicates` expressions. Registration is unconditional: even if the defining plugin's own crate predicates don't match the current workspace, its `[[predicate]]` entries are still available.

When a predicate expression uses a function name that isn't a builtin, Symposium looks it up in the custom predicate registry. If found, it spawns the declared command with the static `args` followed by the raw argument text from the expression.

```toml
[[installations]]
name = "cargo-bp-install"
source = "cargo"
crate = "cargo-bp"
executable = "cargo-bp"

[[predicate]]
name = "battery_pack"
command = "cargo-bp-install"
args = ["bp", "status", "--check"]
```

Usage in a `predicates` expression:

```toml
predicates = ["battery_pack(cli>=0.3)"]
```

This evaluates as:
Comment thread
nikomatsakis marked this conversation as resolved.

```
cargo-bp bp status --check cli>=0.3
```

Exit 0 means the predicate passes; non-zero means it fails.

The argument is trimmed of leading/trailing whitespace before being passed. An empty argument — `battery_pack()` or `battery_pack( )` — does not append anything to the command (only the static `args` are passed).

### Witness output (stdout JSON)

On success (exit 0), the command may write a JSON object to stdout. If present and valid, the `selectedCrates` field drives `source = "crate"` skill resolution — the named crates are fetched for skills, just as if they'd been matched by a `crate(...)` predicate.

```json
{
"selectedCrates": [
{ "crate": "cli-battery-pack", "version": "0.3.1" }
]
}
```

If stdout is empty, the predicate passes but contributes no witness crates. If stdout is non-empty but not valid JSON, or any entry has an invalid `version` field, the predicate **fails** (treated as exit non-zero) and a warning is emitted.

### Collisions

If two plugins define the same predicate name, both definitions are skipped and a warning is emitted. Skills referencing the collided name evaluate as false.

### Caching

Results are cached by `(predicate_name, raw_arg)` for the duration of a single sync run. The same predicate called with the same argument is only spawned once.

## `[[mcp_servers]]`

Each `[[mcp_servers]]` entry declares an MCP server that Symposium registers into the agent's configuration during `sync --agent`.
Expand Down
3 changes: 3 additions & 0 deletions src/help_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ fn collect_section(
builtins.sort();

let mut plugins = applicable_subcommands(registry, deps)
.into_iter()
.filter(|(_, _, subcommand)| subcommand.audience == target)
.map(|(_, name, subcommand)| (name.to_string(), subcommand.description.clone()))
.collect::<Vec<_>>();
Expand Down Expand Up @@ -212,6 +213,7 @@ mod tests {
mcp_servers: vec![],
subcommands,
installations: vec![],
custom_predicates: vec![],
},
source_name: "test".into(),
source_dir: PathBuf::from("/test"),
Expand All @@ -232,6 +234,7 @@ mod tests {
plugins,
standalone_skills: vec![],
warnings: vec![],
custom_predicates: crate::plugins::CustomPredicateRegistry::default(),
}
}

Expand Down
19 changes: 10 additions & 9 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ fn discovery_hint(sym: &Symposium, cwd: &Path) -> Option<String> {
let registry = load_registry(sym);
let deps = crate::crate_sources::crate_pairs(&workspace_crates(cwd));

let any_subcommand = applicable_subcommands(&registry, &deps).next().is_some();
let any_subcommand = !applicable_subcommands(&registry, &deps).is_empty();

any_subcommand.then(|| {
format!(
Expand Down Expand Up @@ -501,8 +501,8 @@ pub async fn dispatch_plugin_hooks(
} else {
Vec::new()
};
let ctx = crate::predicate::PredicateContext::new(&deps);
let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &ctx);
let mut ctx = crate::predicate::PredicateContext::new(&deps);
let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &mut ctx);

let mut output = prior_output;

Expand Down Expand Up @@ -663,7 +663,7 @@ fn dispatched_hooks_for_payload(
plugins: &[ParsedPlugin],
input: &symposium::InputEvent,
host_agent: HookAgent,
ctx: &crate::predicate::PredicateContext,
ctx: &mut crate::predicate::PredicateContext,
) -> Vec<ResolvedHook> {
tracing::trace!(?input, "matching hooks for payload");

Expand Down Expand Up @@ -968,6 +968,7 @@ mod tests {
skills: vec![],
mcp_servers: vec![],
subcommands: BTreeMap::new(),
custom_predicates: vec![],
};
crate::plugins::ParsedPlugin {
path: std::path::PathBuf::from("test.toml"),
Expand All @@ -993,7 +994,7 @@ mod tests {
&[plugin],
&pre_tool_use_input(),
HookAgent::Claude,
&crate::predicate::PredicateContext::new(&[]),
&mut crate::predicate::PredicateContext::new(&[]),
);
assert!(hooks.is_empty(), "plugin-level false should drop all hooks");
}
Expand All @@ -1005,7 +1006,7 @@ mod tests {
&[plugin],
&pre_tool_use_input(),
HookAgent::Claude,
&crate::predicate::PredicateContext::new(&[]),
&mut crate::predicate::PredicateContext::new(&[]),
);
assert!(hooks.is_empty(), "hook-level false should drop the hook");
}
Expand All @@ -1017,7 +1018,7 @@ mod tests {
&[plugin],
&pre_tool_use_input(),
HookAgent::Claude,
&crate::predicate::PredicateContext::new(&[]),
&mut crate::predicate::PredicateContext::new(&[]),
);
assert_eq!(hooks.len(), 1);
}
Expand All @@ -1038,7 +1039,7 @@ mod tests {
&[plugin.clone()],
&pre_tool_use_input(),
HookAgent::Claude,
&crate::predicate::PredicateContext::new(&[]),
&mut crate::predicate::PredicateContext::new(&[]),
);
assert!(
empty.is_empty(),
Expand All @@ -1051,7 +1052,7 @@ mod tests {
&[plugin],
&pre_tool_use_input(),
HookAgent::Claude,
&crate::predicate::PredicateContext::new(&deps),
&mut crate::predicate::PredicateContext::new(&deps),
);
assert_eq!(
matched.len(),
Expand Down
Loading
Loading