Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Predicates

A predicate decides whether a plugin, skill group, skill, hook, MCP server, or subcommand is active, evaluated against the workspace’s dependency graph and the live environment. There is one predicate model, written two ways:

  • The depends-on field uses dependency-atom syntax (see dependency predicates) and is sugar: depends-on = ["serde", "tokio"] lowers to a single any(depends-on(serde), depends-on(tokio)) predicate.
  • The predicates field uses the function-call syntax below.

Both fields are merged into one list that is ANDed together, so depends-on and predicates compose with AND. A depends-on(...) predicate is available in predicates too — the field just makes the common case terse.

The available predicate functions are:

PredicateHolds when
depends-on(<name>) / depends-on(<name><req>)A workspace dependency named <name> is present (and its version satisfies <req>, e.g. depends-on(serde>=1.0)).
depends-on(*)Any workspace matches (even one with zero dependencies). The lowered form of *.
shell(<command>)<command> run via sh -c exits 0. Any other exit (including spawn failure) fails.
path_exists(<arg>)<arg> resolves to an existing path. An argument with a path separator is checked on the filesystem (cwd-relative or absolute). A bare name with no separator is checked against the cwd and then searched on $PATH, so it matches either a local entry (path_exists(.git)) or an installed binary (path_exists(rg)).
env(<name>)The environment variable <name> is set (to any value).
env(<name>=<value>)<name> is set and equals <value> exactly. Only the first = separates name from value, so env(KEY=a=b) matches the value a=b.
workspace-member()The plugin this predicate belongs to is defined by a member of the active workspace (a workspace plugin). Takes no argument.
not(<predicate>)The inner predicate does not hold. The only way to express absence.
any(<p>, <p>, …)At least one inner predicate holds (logical OR).
all(<p>, <p>, …)Every inner predicate holds (logical AND).

Predicates compose with AND semantics within a list: every entry must hold. any(...) gives OR within a single entry, all(...) gives an explicit AND group, and not(...) gives negation — together they form full boolean logic. They also compose with AND across levels (plugin ∧ group ∧ skill).

The argument of a leaf predicate (depends-on, shell, path_exists, env) is taken verbatim between the parentheses — it is not quoted. shell(command -v rg) runs command -v rg; do not wrap the argument in quotes (they would become part of the command). An inner ) is fine as long as parentheses balance, so shell(echo $(date)) works. The combinators not, any, and all take nested predicates as their arguments and may be nested arbitrarily, e.g. not(any(env(CI), path_exists(.skip))).

crate(...) is the retired spelling of depends-on(...) and is rejected at parse time with a migration hint.

Crate-sourced skills and the witness

For a [[skills]] group with source = "crate", the depends-on(...) predicates do double duty: they gate the group and name which crates’ source trees to fetch skills from. The fetch set is the predicate’s witness — the packages that participate in a satisfying evaluation: depends-on(c) contributes c when present, any contributes its true branches, all contributes all branches when it holds, and not(...) contributes nothing. So all(depends-on(serde), env(USE_SERDE)) only fetches serde when USE_SERDE is set, while any(depends-on(fd), depends-on(fdfind)) fetches whichever are present.

A group using source = "crate" must name at least one concrete dependency in a fetchable position — somewhere (plugin- or group-level) that can appear in a witness. depends-on(*) alone is rejected since there is nothing concrete to fetch, and a dependency named only under not(...) is rejected too: not(depends-on(legacy)) gates the group but, having an empty witness, names no crate to fetch from. Put the dependency positively (e.g. depends-on(serde), or any(depends-on(serde), not(depends-on(legacy)))).

When predicates are evaluated

Predicates are evaluated at the same point the workspace’s dependency predicates are evaluated for that item:

LevelEvaluated
Plugin predicatesAt sync (gates skills & MCP) and at every hook dispatch
Skill group predicatesAt sync, before any git/crates source is fetched
Skill frontmatter predicatesAt sync, after the skill loads
Hook predicatesAt hook dispatch, after the matcher passes
MCP server predicatesAt sync, when collecting servers to register

Hook-level predicates run at dispatch (not sync) so they observe live state — e.g. a hook gated on path_exists(jq) will silently disable itself if jq was uninstalled since the last sync, without forcing a re-sync.

Tip: keep predicates fast and side-effect free (path_exists(rg), path_exists(.git), shell(test -f Cargo.toml)). Plugin- and hook-level predicates fire on every hook dispatch.

Usage

Plugin manifests (TOML)

name = "my-plugin"
depends-on = ["*"]
predicates = ["path_exists(rg)", "shell(test -f Cargo.toml)"]

[[skills]]
depends-on = ["serde"]
predicates = ["path_exists(jq)"]
source = "crate"

[[hooks]]
name = "h"
event = "PreToolUse"
command = { script = "scripts/x.sh" }
predicates = ["path_exists(.git)"]

[[mcp_servers]]
name = "tool"
command = "/usr/local/bin/tool"
args = []
env = []
predicates = ["path_exists(tool)"]

Skill frontmatter (YAML)

Like depends-on, predicates is comma-separated on a single line in SKILL.md frontmatter. Commas inside (...) are not treated as separators, so a shell(...) command may itself contain commas:

---
name: my-skill
description: Skill that depends on ripgrep
depends-on: serde
predicates: path_exists(rg), shell(test -f Cargo.toml)
---

Example: gating a plugin on tool availability

name = "uses-jq"
depends-on = ["*"]
predicates = ["path_exists(jq)"]

[[hooks]]
name = "format-json"
event = "PreToolUse"
command = { script = "scripts/format.sh" }

The hook here only registers if jq is on the user’s $PATH. No error, no warning — symposium just skips this plugin’s contributions while jq is missing.

Combining predicates

depends-on, env, not, any, and all cover the cases plain depends-on lists can’t:

# Opt-in: only when a flag is set.
predicates = ["env(SYMPOSIUM_EXPERIMENTAL)"]

# Opt-out / escape hatch: skip when a marker file is present, or in CI.
predicates = ["not(path_exists(.skip-hooks))", "not(env(CI))"]

# Tool packaged under different names across distros.
predicates = ["any(path_exists(fd), path_exists(fdfind))"]

# A dependency gate that also requires an env flag (vs. the bare `depends-on = ["serde"]`).
predicates = ["all(depends-on(serde), env(USE_SERDE))"]

# Apply only when a dependency is absent (impossible with `depends-on`).
predicates = ["not(depends-on(legacy-thing))"]

These are equivalent — depends-on is just the terse form for the common case:

depends-on = ["serde", "tokio"]
# is exactly
predicates = ["any(depends-on(serde), depends-on(tokio))"]