Skip to content
Open
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
4 changes: 4 additions & 0 deletions .agents/skills/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Skills symposium syncs in are generated; ignore them, keep hand-authored ones.
/*
!/.gitignore
!/authoring-rfds/
8 changes: 4 additions & 4 deletions md/design/important-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ This section describes the logic of each `cargo agents` command.

A plugin loads a crate as a plugin by naming that crate in a `[[plugins]]` chained reference (`source.cargo = "..."`). When the owning plugin is active and the edge's predicates hold, sync resolves the crate. A single path handles every crate — a crate is always a first-class plugin, whether it describes itself with a `SYMPOSIUM.toml`, with `[package.metadata.symposium]`, with both, or with neither:

1. `skills_applicable_to` runs `expand_chained_plugins` over the active plugin's `plugin.chained` edges; each edge whose predicates hold (evaluated against the *owning* plugin's provenance) names a crate directly.
2. For that crate, `expand_chained_plugins` calls `CargoPm::load_plugin(name, workspace)`:
1. `resolve_applicable`'s `Resolver` runs `expand_chained` over the active plugin's `plugin.chained` edges; each edge whose predicates hold (evaluated against the *owning* plugin's provenance) names a crate directly.
2. For that crate, `Resolver::expand_chained` calls `CargoPm::load_plugin(name, workspace)`:
- `CargoPm::fetch` resolves the source via `RustCrateFetch` (path overrides for local path deps, then the cargo registry cache, then crates.io). The fetched id carries the exact resolved version.
- `plugins::load_crate_manifest` builds the plugin definition by layering three sources (merge order: crate defaults → `[package.metadata.symposium]` from `Cargo.toml` → `SYMPOSIUM.toml` file). Both manifest sources use the ordinary plugin-manifest schema and are parsed **leniently** (a malformed layer is logged and dropped). Validation runs under `ManifestOrigin::Crate` (name defaults to the crate, `depends-on` is waived, `[defaults]` accepted, default `skills/` group appended unless `[defaults] skills = false`). The result is a `ParsedPlugin` whose `canonical` id is the resolved crate. A crate with no manifest sources still yields one whose only content is that default `skills/` group.
3. Back in `expand_chained_plugins`, the crate plugin's own plugin-level predicates are honored (`applies`, which stamps its provenance — never a workspace member), its skill groups run through the ordinary `load_skills_for_group` pipeline — honoring named groups, group predicates, and `source.path`/`source.git`, with each discovered skill's origin hashed from its on-disk `SKILL.md` path — and **its own `[[plugins]]` edges are expanded in turn**. This is how a `[package.metadata.symposium]` redirect (now a `[[plugins]] source.cargo` chained reference to the target crate) is followed. A per-top-level-plugin `visited` set keyed on the normalized crate name collapses diamonds (a crate reached two ways loads once) and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed but not yet dispatched (a `warn_undispatched_crate_features` notice fires when present).
3. Back in `Resolver::expand_chained`, the crate plugin's own plugin-level predicates are honored (`applies`, which stamps its provenance — never a workspace member), its skill groups run through the ordinary `load_skills_for_group` pipeline — honoring named groups, group predicates, and `source.path`/`source.git`, with each discovered skill's origin hashed from its on-disk `SKILL.md` path — and **its own `[[plugins]]` edges are expanded in turn**. This is how a `[package.metadata.symposium]` redirect (now a `[[plugins]] source.cargo` chained reference to the target crate) is followed. A per-top-level-plugin `visited` set keyed on the normalized crate name collapses diamonds (a crate reached two ways loads once) and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed but not yet dispatched (a `warn_undispatched_crate_features` notice fires when present).

A skill's install identity is the hash of its on-disk `SKILL.md` path, so a crate reached two ways dedupes to one install. The edge's version requirement is recorded but not yet enforced — the crate resolves against the workspace (pin / path override).

The key code paths are in `pm/cargo.rs` (`CargoPm::load_plugin`), `plugins.rs` (`load_crate_manifest`, `RawPluginManifest::merge`, `ManifestOrigin::Crate`, `ParsedPlugin::canonical`), `skills.rs` (`expand_chained_plugins`, `hash_origin_key`), `crate_metadata.rs` (`symposium_metadata`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`).
The key code paths are in `pm/cargo.rs` (`CargoPm::load_plugin`), `plugins.rs` (`load_crate_manifest`, `RawPluginManifest::merge`, `ManifestOrigin::Crate`, `ParsedPlugin::canonical`), `skills.rs` (`Resolver::expand_chained`, `hash_origin_key`), `crate_metadata.rs` (`symposium_metadata`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`).

## Help rendering

Expand Down
16 changes: 12 additions & 4 deletions md/design/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Everything hangs off the `Symposium` struct, which wraps the parsed `Config` wit

Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, plugin sources, defaults, and `auto-update` (off/warn/on, default on). User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `plugin_sources()` to resolve the effective list of plugin source directories. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching.

`record_telemetry_batch(session_id, kinds)` is where `config.telemetry.enabled` is checked: it records a hook pass's events via [telemetry](./telemetry.md) when enabled and does nothing otherwise (`record_telemetry` is a one-event convenience wrapper over it). Callers therefore emit unconditionally. `roll_off_telemetry()` enforces retention, run once per session on `SessionStart`. It is deliberately *not* gated on `enabled`: retention is disk hygiene, so data recorded before a user opted out still ages out. Both are best-effort (a write failure never breaks a hook) and return nothing.

### `agents.rs` — agent abstraction

Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration).
Expand All @@ -20,9 +22,9 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`--

Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_skill_dir(source_dir, dest_dir, project_root)`. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs.

Two entry points: `sync(sym, cwd)` for standalone CLI use (creates its own `WorkspaceDeps`) and `sync_with_deps(sym, deps)` for the hook pipeline (shares the cached workspace resolution with other hook stages).
`sync(sym, deps, update)` runs the pass and returns a `SyncSummary`: the matched plugins and skills (each with the crates that activated it), plus `installed` (distinct skills this pass created or updated, counted once per skill regardless of how many agents it was written to, so a steady-state sync reports 0), `reaped` (stale directories removed, counted per directory), and the workspace `crate_count`. The hook pipeline emits telemetry from this; the standalone `cargo agents sync` path discards it. The summary is built from data `sync` already computes, so nothing extra is resolved when telemetry is off.

`sync` takes an `UpdateLevel` that it threads into skill resolution (`skills_applicable_to`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`.
`sync` takes an `UpdateLevel` that it threads into skill resolution (`resolve_applicable`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`.

### `plugins.rs` — plugin registry

Expand Down Expand Up @@ -74,7 +76,11 @@ Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) sto

Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources, discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. Every `source` funnels through one seam: `resolve_group_dirs` turns a group into a list of `ResolvedSkillDir` (a base directory + report labels), then `collect_skills_from_dirs` scans each base for `SKILL.md` files. `PluginSource` has exactly two variants — `Path` (already on disk, relative to the plugin's source dir) and `Git` (fetched via the git cache); a source is required, so there is no "no source" state.

`[[plugins]]` chained references are how a crate becomes a plugin: after an active plugin's own skill groups, `skills_applicable_to` runs `expand_chained_plugins` over its `plugin.chained` edges. For each edge whose predicates hold it asks `CargoPm::load_plugin` for the crate, which always returns a first-class `ParsedPlugin` (built from `[package.metadata.symposium]` + `SYMPOSIUM.toml` + defaults). That plugin's own plugin-level predicates are honored, its skill groups run through the ordinary `load_skills_for_group` pipeline, and **its own `[[plugins]]` edges are expanded in turn** — a crate that names another crate (the reschema'd `[package.metadata.symposium]` redirect) is followed recursively. A per-top-level-plugin `visited` set (keyed on the normalized crate name via `canonical`) collapses diamonds and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed and carried but **not yet dispatched** (`warn_undispatched_crate_features` logs when present). Each discovered skill's install identity is the hash of its on-disk `SKILL.md` path (below), so a crate reached two ways dedupes to one install.
`resolve_applicable` is the entry point. It returns `ApplicableSkills { skills, plugins }`: the applicable skills (each `SkillWithGroupContext` carries its owning `plugin` and the `crates` that activated it) plus a `PluginActivation` per matched plugin (tracked separately, since a plugin can match yet vend no skill). The walk lives on a `Resolver` that bundles the shared inputs, the predicate cursor, and the two growing lists, so the recursive chained-plugin descent threads only an `inherited` crate set instead of a long argument list. `skills_applicable_to` is a test-only wrapper returning just the skills.

A skill's (and plugin's) `crates` is a coarse, names-only attribution that feeds [telemetry](./telemetry.md): the dependency names the gate references (via `PredicateSet::collect_dep_names`) intersected with the workspace deps, then unioned down the plugin, group, and skill levels (`Resolver::matched_names`). It stands in for a retired predicate witness, and over-reports only a satisfied `any(...)`'s non-firing branch, which is acceptable for a names-only field.

`[[plugins]]` chained references are how a crate becomes a plugin: after an active plugin's own skill groups, `Resolver::expand_chained` runs over its `plugin.chained` edges. For each edge whose predicates hold it asks `CargoPm::load_plugin` for the crate, which always returns a first-class `ParsedPlugin` (built from `[package.metadata.symposium]` + `SYMPOSIUM.toml` + defaults). That plugin's own plugin-level predicates are honored, its skill groups run through the ordinary `load_skills_for_group` pipeline, and **its own `[[plugins]]` edges are expanded in turn** — a crate that names another crate (the reschema'd `[package.metadata.symposium]` redirect) is followed recursively. A per-top-level-plugin `visited` set (keyed on the normalized crate name via `canonical`) collapses diamonds and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed and carried but **not yet dispatched** (`warn_undispatched_crate_features` logs when present). Each discovered skill's install identity is the hash of its on-disk `SKILL.md` path (below), so a crate reached two ways dedupes to one install.

Each applicable skill carries an **origin hash** (a `String`) describing *where its bytes live*, used at sync time for dedup and install-path disambiguation. `skill_origin_hash` computes it as an 8-hex-char prefix of SHA-256 over the `SKILL.md`'s **canonical** on-disk path — nothing else. Identity is the file's location, not which plugin manifest pointed at it: two references that resolve to the same file (the same crate reached through two chained plugins, or a `source.path` group and the standalone walk landing on the same bundle) produce the same hash and dedupe; skills at different paths stay distinct. Canonicalizing inside the hash is what makes that hold across discovery paths — group discovery walks a canonicalized scan dir while `plugins.rs`'s standalone walk uses the configured source path verbatim, so on a platform whose temp prefix is a symlink (macOS `/var` → `/private/var`) the same file would otherwise hash two ways and install twice.

Expand Down Expand Up @@ -102,13 +108,15 @@ Handles the hook pipeline: parse agent wire-format input → auto-sync → built

Builtin dispatch currently only acts on `SessionStart`, where `handle_session_start` composes two independently-computed `additionalContext` fragments: a `discovery_hint` (suggests `cargo agents --help` when the workspace exposes applicable plugin subcommands, reusing `subcommand_dispatch::applicable_subcommands`) and an `update_nudge` (the throttled self-update warning); the discovery hint is not gated behind the update-check throttle. The plugin dispatch path matches plugin `Hook`s against the event, selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a `ResolvedHook` per match (looking up the named installations on the plugin), then for each `ResolvedHook`: acquires its `requirements` (best-effort), runs `install_commands` after the source step, picks a `Runnable` from (hook-or-install) `executable`/`script`, and spawns it (binary directly for `Exec`, via `sh <path>` for `Script`). Input is delivered in the selected format; output is converted back to the agent's wire format before returning.

Telemetry emission is centralized in `execute_hook`. `run_auto_sync` returns `Option<SyncSummary>` (`None` when auto-sync is off, debounced, or the sync errored) and `dispatch_plugin_hooks` returns a `DispatchOutcome`: the `Vec<HookInvocation>` it timed (each hook's run duration, exit code included) and a `Result` holding either the merged output or a blocking hook's stderr. After dispatch, the pure `telemetry_events` maps the wire input, the optional `SyncSummary`, and the hook invocations into the `EventKind`s for this pass, which `execute_hook` records through `sym.record_telemetry_batch` (the opt-in gate) in one batched write, so a pass opens the day's file once rather than reopening per event. Cadence: the wire funnel event (`session_start` / `user_prompt` / `tool_use` / `stop`) is emitted once per matching hook event (`PreToolUse` is silent, so a tool is counted once on `PostToolUse`); `sync_run` whenever the sync body ran; `plugin_activation` / `skill_activation` only on the `SessionStart` sync, so the activation snapshot is recorded once per session. `session_id` comes from the wire input and rides on the event envelope. When a plugin hook blocks (exit 2 / killed), dispatch still returns the invocations it collected, so `execute_hook` emits the pass's telemetry (the wire event, `sync_run`, and every hook that ran, including the blocking one) before propagating the block. On `SessionStart`, `execute_hook` also calls `sym.roll_off_telemetry()` once per session: the only place stale daily files past `RETENTION_DAYS` are deleted.

### `state.rs` — persistent state

Manages `state.toml` in the config directory. Deserializes through `RawState` and validates into the runtime `State`. Tracks the semver of the binary that last touched the directory (for future migration hooks) and the timestamp of the last update check (to throttle crates.io queries to once per 24 hours). `ensure_current()` is called on startup to silently stamp the current version. `should_check_for_update()` / `record_update_check()` gate the auto-update flow.

### `telemetry.rs` — opt-in usage telemetry

Implements the local, opt-in [telemetry](./telemetry.md) event log under `<config-dir>/telemetry/`, one JSONL file per UTC day. Off by default; gated by `[telemetry] enabled`. A `TelemetryEvent` is an `at` timestamp plus a kind-tagged `EventKind` (`session_start` / `user_prompt` / `tool_use`), serialized one per line. `record` / `record_kind` append an event; `roll_off` deletes files older than `RETENTION_DAYS` (30); `read_events` / `recent_events` read them back; `usage` + `status_text` back `telemetry status`; `recent_events` backs `telemetry show`. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The recording entry points are not yet called from the hook pipeline, so no events are produced today even when telemetry is enabled.
Implements the local, opt-in [telemetry](./telemetry.md) event log under `<config-dir>/telemetry/`, one JSONL file per UTC day. Off by default; gated by `[telemetry] enabled`. A `TelemetryEvent` is an `at` timestamp, an optional `session_id` (common to every kind, so it rides on the envelope rather than being repeated in each), plus a kind-tagged `EventKind` (`session_start` / `user_prompt` / `tool_use` / `plugin_activation` / `skill_activation` / `hook_invocation` / `sync_run` / `stop`), serialized one per line. `record` appends one event; `record_all` appends a whole hook pass with a single file open (the production path, used by the hook pipeline); `roll_off` deletes files older than `RETENTION_DAYS` (30), run once per session from the `SessionStart` hook via `Symposium::roll_off_telemetry`; `read_events` / `recent_events` read them back; `usage` + `status_text` back `telemetry status`; `recent_events` backs `telemetry show`. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The hook pipeline produces these events: `execute_hook` maps each pass to `EventKind`s and records them through `Symposium::record_telemetry_batch` (the opt-in gate), so events are written only when `[telemetry] enabled` is set.

### `report.rs` — structured report layer

Expand Down
Loading
Loading