diff --git a/Cargo.lock b/Cargo.lock index 93ad2d1f..1d00b37e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2507,6 +2507,8 @@ dependencies = [ "tempfile", "tokio", "toml", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 16679fba..02579c9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ symposium-hook = { path = "symposium-hook", features = ["clap"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } toml = "0.8" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "registry"] } bytes = "1.11.1" dialoguer = "0.12.0" toml_edit = "0.25.11" diff --git a/md/SUMMARY.md b/md/SUMMARY.md index a1f02c20..9af1bcfd 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -62,6 +62,7 @@ - [Session state](./design/session-state.md) - [Hooks](./design/hooks.md) - [Subcommands](./design/subcommands.md) + - [Report layer](./design/report-layer.md) - [Important flows](./design/important-flows.md) - [`init`](./design/init-user-flow.md) - [`sync`](./design/sync-agent-flow.md) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 5bbd37f5..d4b9d628 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -69,6 +69,18 @@ Handles the hook pipeline: parse agent wire-format input → auto-sync → built Manages `state.toml` in the config directory. 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. +### `report.rs` — structured report layer + +Provides user-facing output for all commands via a custom tracing layer. Commands emit `tracing::info!` or `tracing::debug!` events with a `report = %ReportEvent::Variant { ... }` field; the `ReportLayer` intercepts these and renders them based on mode: + +- `Normal` — prints `format_human()` to stdout (default for most commands) +- `Verbose` (`-v`) — prints all events (info + debug) to stderr +- `Json` (`--json`) — accumulates events in a buffer, drained as a JSON array at the end + +The `ReportEvent` enum is the stable schema — `#[derive(Serialize, Deserialize)]` with `#[serde(tag = "kind")]`. Each variant carries the fields needed to render both human and JSON forms. The `Display` impl serializes to JSON (for passing through tracing's `%` formatter), and `format_human()` renders the pretty-printed form. + +The layer is always installed by the binary. Commands that want output simply emit report events at the appropriate tracing level (info for actions, debug for decision trace). The `--json` flag also suppresses the `Output`-based messages and drains the JSON buffer at exit. + ### `self_update.rs` — self-update Implements `cargo agents self-update`. Queries the registry for the latest published version via `cargo search`, then installs it via `cargo install symposium --force`. Also provides `re_exec()` which replaces the current process with the newly installed binary (Unix `exec`, spawn-and-exit on Windows) — used by the `auto-update = "on"` startup path. Contains `maybe_warn_for_update()` (sync, for the `warn` library path) and `maybe_check_for_update()` (async, for the binary `on` + re-exec path). diff --git a/md/design/report-layer.md b/md/design/report-layer.md new file mode 100644 index 00000000..47e6f4e9 --- /dev/null +++ b/md/design/report-layer.md @@ -0,0 +1,117 @@ +# Structured report layer + +Commands produce user-facing output by emitting tracing events with a `report` field. A custom tracing layer (`ReportLayer`) intercepts these events and renders them in one of three modes depending on CLI flags. + +## How it works + +``` +Command code Tracing infrastructure User +───────────── ────────────────────── ──── +tracing::info!( → ReportLayer::on_event() → stdout/stderr/JSON + report = %ReportEvent::SkillInstalled { ... } +) +``` + +1. Command code emits a tracing event at `info` (actions) or `debug` (decisions) level, carrying a single `report` field whose value is a `ReportEvent` formatted via `Display` (which serializes to JSON). +2. The `ReportLayer` checks: does this event have a `report` field? Is its level within `max_level`? +3. If yes, it deserializes the JSON string back into a `ReportEvent` and renders it based on mode. + +## Modes + +| Mode | CLI flags | Output target | Level filter | +|------|-----------|---------------|--------------| +| `Normal` | (none) | stdout | INFO only | +| `Verbose` | `-v` | stderr | INFO + DEBUG | +| `Json` | `--json` | buffered → stdout at exit | INFO (or DEBUG with `-v --json`) | + +The layer is **always installed** — commands don't need to check whether reporting is active. + +## Adding a report event to a new command + +### Step 1: Add a variant to `ReportEvent` + +In `src/report.rs`, add a new variant to the enum: + +```rust +/// A frobnitz was reticulated. +FrobnitzReticulated { + name: String, + count: usize, +}, +``` + +Rules for variants: +- Use `#[serde(skip_serializing_if = "Option::is_none")]` for optional fields +- Don't use a field named `kind` (conflicts with `#[serde(tag = "kind")]`) +- Keep fields simple (String, bool, usize, Option) + +### Step 2: Add a `format_human` arm + +In the `format_human()` method, add a rendering arm: + +```rust +Self::FrobnitzReticulated { name, count } => { + format!("✅ reticulated {name} ({count} nodes)") +} +``` + +Use emoji prefixes to match the existing style: +- `✅` — success/action taken +- `➖` — removal +- `⚠️ ` — warning +- `ℹ️ ` — informational +- `🟢` — already in place / no-op + +### Step 3: Emit from command code + +```rust +tracing::info!( + report = %crate::report::ReportEvent::FrobnitzReticulated { + name: frobnitz.name.clone(), + count: frobnitz.nodes.len(), + }, +); +``` + +Use `tracing::info!` for actions the user should always see, `tracing::debug!` for decision-trace detail that only appears with `-v`. + +## Level conventions + +| Level | When to use | Visible in | +|-------|-------------|------------| +| `info` | Actions taken (installed, removed, validated) | Normal, Verbose, Json | +| `debug` | Decisions (plugin matched, skill skipped, directory searched) | Verbose only (or `-v --json`) | + +## The `Info` and `Warning` variants + +For messages that don't map to a specific structured event, use the generic variants: + +```rust +tracing::info!( + report = %crate::report::ReportEvent::Info { + message: format!("scanning {} workspace dependencies", count), + }, +); +``` + +Prefer specific variants over `Info`/`Warning` when the data is structured — they produce better JSON output. + +## Testing + +The test harness (`symposium-testlib`) provides `sync_with_report()` which installs a scoped `Json`-mode layer and returns captured events. Tests assert on the JSON structure: + +```rust +let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; +let installed: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "skill_installed") + .collect(); +assert!(!installed.is_empty()); +``` + +## Architecture notes + +- The `Display` impl on `ReportEvent` serializes to JSON — this is how the value passes through tracing's `%` formatter into the visitor +- The layer's visitor checks `record_debug` (not `record_str`) because `%` goes through the debug path +- Per-layer `EnvFilter`s ensure the report layer receives all events regardless of file log level +- The `ReportHandle` (returned alongside the layer) allows draining accumulated JSON after the command completes diff --git a/md/reference/cargo-agents-sync.md b/md/reference/cargo-agents-sync.md index 6d827dac..e3e4ea84 100644 --- a/md/reference/cargo-agents-sync.md +++ b/md/reference/cargo-agents-sync.md @@ -8,6 +8,8 @@ Synchronize skills with workspace dependencies. cargo agents sync ``` +With the global `-v` flag, sync additionally shows each plugin, skill group, and skill that was evaluated and why each was included or skipped. With `--json`, stdout receives a JSON array of structured event objects (see [global options](./cargo-agents.md#global-options)). + ## Behavior Must be run from within a Rust workspace. Performs the following steps: diff --git a/md/reference/cargo-agents.md b/md/reference/cargo-agents.md index 1ee91a80..54a498ca 100644 --- a/md/reference/cargo-agents.md +++ b/md/reference/cargo-agents.md @@ -16,7 +16,11 @@ | Flag | Description | |------|-------------| +| `-v`, `--verbose` | Print detailed decision trace (which plugins matched, which skills were considered, etc.) | +| `--json` | Output structured JSON report to stdout; suppresses human-readable output. Combine with `-v` to include the full decision trace. | | `--update ` | Plugin source update behavior: `none` (default), `check`, `fetch` | | `-q`, `--quiet` | Suppress status output | | `--help` | Print help | | `--version` | Print version | + +The `-v` and `--json` flags work with `sync`, `plugin list`, and `plugin validate`. During hook dispatch, decision events are emitted at debug level and appear in verbose output when testing hooks. diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 9b5ecbf7..66259178 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -8,6 +8,7 @@ use symposium::help_render; use symposium::hook; use symposium::output::Output; use symposium::plugins; +use symposium::report; use symposium::self_update; use symposium::state; use symposium::subcommand_dispatch::dispatch_external; @@ -15,7 +16,6 @@ use symposium::subcommand_dispatch::dispatch_external; #[tokio::main] async fn main() -> ExitCode { let mut sym = config::Symposium::from_environment(); - sym.init_logging(); // When invoked as `cargo agents`, cargo passes "agents" as the first arg. // Strip it so clap sees the real arguments. @@ -52,6 +52,23 @@ async fn main() -> ExitCode { Err(err) => err.exit(), }; + // Always install the report layer. Mode determines output format: + // --json → accumulate JSON array; -v → stderr trace; default → stdout. + let (mode, level) = if cli.json { + let level = if cli.verbose { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }; + (report::ReportMode::Json, level) + } else if cli.verbose { + (report::ReportMode::Verbose, tracing::Level::DEBUG) + } else { + (report::ReportMode::Normal, tracing::Level::INFO) + }; + let (report_layer, report_handle) = report::ReportLayer::new(mode, level); + sym.init_logging(Some(report_layer)); + // Log the command being invoked match &cli.command { Some(Commands::Init { .. }) => tracing::info!("cargo agents init"), @@ -75,9 +92,10 @@ async fn main() -> ExitCode { // Stamp state.toml with the running binary version (silently updates on mismatch). state::ensure_current(sym.config_dir()); - // Hook commands are quiet by default (they're invoked by the agent, not the user) + // Hook commands are quiet by default (they're invoked by the agent, not the user). + // JSON mode also suppresses human output (only JSON goes to stdout). let is_hook = matches!(cli.command, Some(Commands::Hook { .. })); - let out = if cli.quiet || is_hook { + let out = if cli.quiet || is_hook || cli.json { Output::quiet() } else { Output::normal() @@ -104,7 +122,14 @@ async fn main() -> ExitCode { // Commands that need direct I/O (stdin/stdout) stay in the binary Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, - Some(Commands::Plugin { command }) => handle_plugin_command(&sym, command).await, + Some(Commands::Plugin { command }) => { + let code = handle_plugin_command(&sym, command).await; + let events = report_handle.drain(); + if !events.is_empty() { + println!("{}", serde_json::to_string_pretty(&events).unwrap()); + } + code + } Some(Commands::External(argv)) => match dispatch_external(&sym, &cwd, argv).await { Ok(result) => { @@ -124,7 +149,13 @@ async fn main() -> ExitCode { // Everything else delegates to the library Some(cmd) => match symposium::cli::run(&mut sym, cmd, &cwd, &out).await { - Ok(()) => ExitCode::SUCCESS, + Ok(()) => { + let events = report_handle.drain(); + if !events.is_empty() { + println!("{}", serde_json::to_string_pretty(&events).unwrap()); + } + ExitCode::SUCCESS + } Err(e) => { eprintln!("Error: {e:#}"); ExitCode::FAILURE @@ -160,25 +191,15 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) PluginCommand::List => { let providers = plugins::list_plugins(sym); for provider in &providers { - println!("Provider: {}", provider.name); - println!(" Type: {}", provider.source_type); - if let Some(ref url) = provider.git_url { - println!(" URL: {url}"); - } - if let Some(ref path) = provider.path { - println!(" Path: {path}"); - } - if provider.plugins.is_empty() { - println!(" (no plugins)"); - } else { - for plugin in &provider.plugins { - println!( - " - {} ({} hooks, {} skill groups)", - plugin.name, plugin.hooks_count, plugin.skill_groups_count - ); - } - } - println!(); + tracing::info!( + report = %report::ReportEvent::ProviderListed { + name: provider.name.clone(), + source_type: provider.source_type.to_string(), + url: provider.git_url.clone(), + path: provider.path.clone(), + plugins: provider.plugins.iter().map(|p| p.name.clone()).collect(), + }, + ); } ExitCode::SUCCESS } @@ -196,11 +217,8 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) return ExitCode::FAILURE; } for r in &results { - errors += print_validation_result(r, " "); + errors += emit_validation_results(r); } - let total = count_results(&results); - let passed = total - errors; - println!("\n {passed}/{total} valid"); } Err(e) => { eprintln!("✗ {}: {e}", path.display()); @@ -211,23 +229,32 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) if !no_check_crates { match plugins::collect_crate_names_in_source_dir(&path) { Ok(crate_names) => { - if !crate_names.is_empty() { - println!( - "\n📦 Checking {} crate name(s) on crates.io...", - crate_names.len() + for name in &crate_names { + let exists = plugins::check_crate_exists(name).await; + tracing::info!( + report = %report::ReportEvent::Validated { + path: name.clone(), + item_kind: "crate".into(), + valid: exists, + error: if exists { None } else { Some("not found on crates.io".into()) }, + warning: None, + }, ); - for name in &crate_names { - if plugins::check_crate_exists(name).await { - println!(" ✅ {name}"); - } else { - eprintln!(" ✗ {name} — not found on crates.io"); - errors += 1; - } + if !exists { + errors += 1; } } } Err(e) => { - eprintln!("✗ failed to collect crate names: {e}"); + tracing::info!( + report = %report::ReportEvent::Validated { + path: path.display().to_string(), + item_kind: "crate-check".into(), + valid: false, + error: Some(format!("failed to collect crate names: {e}")), + warning: None, + }, + ); errors += 1; } } @@ -267,28 +294,35 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) } } -fn print_validation_result(r: &plugins::ValidationResult, indent: &str) -> usize { +fn emit_validation_results(r: &plugins::ValidationResult) -> usize { let mut errors = 0; match &r.result { Ok(()) => { - if let Some(ref w) = r.warning { - println!("{indent}⚠️ {} ({}): {w}", r.path.display(), r.kind); - } else { - println!("{indent}✅ {} ({})", r.path.display(), r.kind); - } + tracing::info!( + report = %report::ReportEvent::Validated { + path: r.path.display().to_string(), + item_kind: r.kind.to_string(), + valid: true, + error: None, + warning: r.warning.clone(), + }, + ); } Err(e) => { - eprintln!("{indent}✗ {} ({}): {e}", r.path.display(), r.kind); + tracing::info!( + report = %report::ReportEvent::Validated { + path: r.path.display().to_string(), + item_kind: r.kind.to_string(), + valid: false, + error: Some(e.to_string()), + warning: None, + }, + ); errors += 1; } } - let child_indent = format!("{indent} "); for child in &r.children { - errors += print_validation_result(child, &child_indent); + errors += emit_validation_results(child); } errors } - -fn count_results(results: &[plugins::ValidationResult]) -> usize { - results.iter().map(|r| 1 + count_results(&r.children)).sum() -} diff --git a/src/cli.rs b/src/cli.rs index 02426171..9bf8bb40 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,9 +36,17 @@ pub struct Cli { pub update: symposium_install::UpdateLevel, /// Suppress status output - #[arg(short, long, global = true)] + #[arg(short = 'q', long, global = true)] pub quiet: bool, + /// Print detailed information about decisions made + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Output structured JSON report + #[arg(long, global = true)] + pub json: bool, + /// Print help #[arg(short = 'h', long = "help", global = true)] pub help: bool, @@ -170,7 +178,7 @@ pub async fn run(sym: &mut Symposium, cmd: Commands, cwd: &Path, out: &Output) - init::init(sym, out, &opts).await } - Commands::Sync => sync::sync(sym, cwd, out).await, + Commands::Sync => sync::sync(sym, cwd).await, Commands::SelfUpdate => self_update::self_update(sym, out), diff --git a/src/config.rs b/src/config.rs index a5ef7bff..967167bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -285,11 +285,18 @@ impl Symposium { self.cargo_override = Some(path); } - /// Initialize logging. Call once at startup. - pub fn init_logging(&self) { + /// Initialize logging with an optional report layer. Call once at startup. + /// + /// When `report_layer` is `Some`, the layer is composed into the + /// subscriber so it receives events alongside the file logger. + /// Per-layer filtering ensures the report layer can receive debug + /// events even when the file log level is set higher. + pub fn init_logging(&self, report_layer: Option) { use std::fs::OpenOptions; use tracing_subscriber::EnvFilter; - use tracing_subscriber::fmt; + use tracing_subscriber::Layer as _; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; let logs = self.logs_dir(); let now = chrono::Local::now(); @@ -303,12 +310,21 @@ impl Symposium { .expect("failed to open log file"); let level = self.log_level(); - let filter = EnvFilter::new(level.as_str()); + let file_filter = EnvFilter::new(level.as_str()); - fmt() - .with_env_filter(filter) + let file_layer = tracing_subscriber::fmt::layer() .with_writer(file) .with_ansi(false) + .with_filter(file_filter); + + // The report layer does its own level filtering internally, so + // give it a permissive filter that lets all events through. + let report_filter = EnvFilter::new("trace"); + let report_layer = report_layer.map(|l| l.with_filter(report_filter)); + + tracing_subscriber::registry() + .with(file_layer) + .with(report_layer) .init(); tracing::debug!( diff --git a/src/help_render.rs b/src/help_render.rs index 42de5743..1a34125c 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -261,21 +261,23 @@ mod tests { let ws: Vec = vec![]; expect![[r#" AI the Rust Way - + Usage: cargo agents [OPTIONS] [COMMAND] - + Commands for humans: init Set up user-wide configuration plugin Manage plugins self-update Update symposium to the latest version sync Synchronize skills with workspace dependencies - + Commands for agents: crate-info Find crate sources - + Options: --update Control plugin source update behavior (none, check, fetch) [default: none] [possible values: none, check, fetch] -q, --quiet Suppress status output + -v, --verbose Print detailed information about decisions made + --json Output structured JSON report -h, --help Print help -V, --version Print version "#]] diff --git a/src/hook.rs b/src/hook.rs index bfb08566..5c935d4d 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -257,8 +257,7 @@ async fn run_auto_sync( } tracing::debug!("auto-sync running"); - let out = crate::output::Output::quiet(); - if let Err(e) = crate::sync::sync(sym, &cwd, &out).await { + if let Err(e) = crate::sync::sync(sym, &cwd).await { tracing::warn!(error = %e, "auto-sync during hook failed (continuing)"); return; } @@ -456,7 +455,16 @@ pub async fn dispatch_plugin_hooks( tracing::trace!(?child_out, "hook finished"); - match child_out.status.code() { + let exit_code = child_out.status.code(); + tracing::debug!( + report = %crate::report::ReportEvent::HookDispatched { + plugin: hook.plugin_name.clone(), + hook: hook.hook_name.clone(), + exit_code, + error: None, + }, + ); + match exit_code { None | Some(2) => return Err(child_out.stderr), Some(0) if child_out.stdout.is_empty() => continue, Some(0) => { @@ -504,7 +512,17 @@ pub async fn dispatch_plugin_hooks( } } } - Err(e) => tracing::warn!(error = %e, "failed to spawn hook command"), + Err(e) => { + tracing::debug!( + report = %crate::report::ReportEvent::HookDispatched { + plugin: hook.plugin_name.clone(), + hook: hook.hook_name.clone(), + exit_code: None, + error: Some(e.to_string()), + }, + ); + tracing::warn!(error = %e, "failed to spawn hook command"); + } } } @@ -579,6 +597,16 @@ fn dispatched_hooks_for_payload( let selected = native_match.or(symposium_match); if let Some(hook) = selected { + tracing::debug!( + report = %crate::report::ReportEvent::HookConsidered { + plugin: parsed_plugin.plugin.name.clone(), + hook: hook.name.clone(), + event: format!("{:?}", input.event()), + selected: true, + format: Some(format!("{:?}", hook.format)), + reason: None, + }, + ); match ResolvedHook::build(parsed_plugin, hook) { Ok(dispatched) => out.push(dispatched), Err(e) => { @@ -590,6 +618,22 @@ fn dispatched_hooks_for_payload( ); } } + } else if parsed_plugin + .plugin + .hooks + .iter() + .any(|h| h.event == input.event()) + { + tracing::debug!( + report = %crate::report::ReportEvent::HookConsidered { + plugin: parsed_plugin.plugin.name.clone(), + hook: "(none)".into(), + event: format!("{:?}", input.event()), + selected: false, + format: None, + reason: Some("no matching format for this agent".into()), + }, + ); } } diff --git a/src/lib.rs b/src/lib.rs index b67ed517..83115401 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod hook_schema; pub(crate) mod installation; pub mod output; pub mod plugins; +pub mod report; pub mod self_update; pub mod state; pub mod subcommand_dispatch; @@ -15,7 +16,7 @@ pub mod workspace_state; pub(crate) mod crate_metadata; pub(crate) mod init; -pub(crate) mod sync; +pub mod sync; pub(crate) mod crate_sources; pub(crate) mod predicate; diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 00000000..6aa9dcca --- /dev/null +++ b/src/report.rs @@ -0,0 +1,389 @@ +//! Structured report layer. +//! +//! Emits user-facing events during `cargo agents` commands as tracing events +//! carrying a single `report` field whose value is a serialized +//! `ReportEvent`. A custom tracing layer picks these up and either +//! pretty-prints them (`--verbose`) or accumulates JSON (`--json`). + +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use tracing::field::{Field, Visit}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// The output mode for the report layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportMode { + /// Pretty-print info-level events to stdout as they arrive. + Normal, + /// Pretty-print all events (info + debug) to stderr as they arrive. + Verbose, + /// Accumulate events, emit as a JSON array at the end. + Json, +} + +/// A structured event emitted during command execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReportEvent { + /// A plugin was considered and either matched or was skipped. + PluginConsidered { + plugin: String, + matched: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// A skill group within a plugin was considered. + SkillGroupConsidered { + plugin: String, + #[serde(skip_serializing_if = "Option::is_none")] + group_crates: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source: Option, + matched: bool, + #[serde(skip_serializing_if = "Option::is_none")] + skills_found: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// A directory was searched for SKILL.md files. + SkillSourceSearched { + plugin: String, + source: String, + path: String, + skills_found: usize, + }, + + /// An individual skill was evaluated. + SkillConsidered { + skill: String, + plugin: String, + matched: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// A skill was installed to an agent's directory. + SkillInstalled { + skill: String, + agent: String, + dest: String, + }, + + /// A stale skill directory was removed. + SkillRemoved { path: String }, + + /// A hook was registered for an agent. + HookRegistered { agent: String, hook: String }, + + /// A user-authored skill was propagated to an agent. + SkillPropagated { + skill: String, + agent: String, + dest: String, + }, + + /// An MCP server was registered for an agent. + McpServerRegistered { agent: String, server: String }, + + /// Informational message. + Info { message: String }, + + /// A non-fatal warning. + Warning { message: String }, + + // ── Hook dispatch events ───────────────────────────────────────── + /// A plugin hook was considered for dispatch. + HookConsidered { + plugin: String, + hook: String, + event: String, + selected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// A plugin hook was dispatched (process spawned). + HookDispatched { + plugin: String, + hook: String, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, + + // ── Plugin validate/list events ────────────────────────────────── + /// A plugin or skill was validated. + Validated { + path: String, + item_kind: String, + valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, + }, + + /// A provider was listed with its plugins. + ProviderListed { + name: String, + source_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + plugins: Vec, + }, +} + +impl std::fmt::Display for ReportEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&serde_json::to_string(self).unwrap()) + } +} + +impl ReportEvent { + fn format_human(&self) -> String { + match self { + Self::PluginConsidered { + plugin, + matched, + reason, + } => { + if *matched { + format!(" plugin {plugin}: matched") + } else { + let r = reason.as_deref().unwrap_or("predicates not satisfied"); + format!(" plugin {plugin}: skipped ({r})") + } + } + Self::SkillGroupConsidered { + plugin, + group_crates, + source, + matched, + skills_found, + reason, + } => { + let crates_str = group_crates.as_deref().unwrap_or("*"); + let source_str = source.as_deref().unwrap_or("unknown"); + if *matched { + let count = skills_found.unwrap_or(0); + format!( + " group [{crates_str}] in {plugin}: matched, source={source_str}, {count} skill(s) found" + ) + } else { + let r = reason.as_deref().unwrap_or("predicates not satisfied"); + format!(" group [{crates_str}] in {plugin}: skipped ({r})") + } + } + Self::SkillSourceSearched { + plugin, + source, + path, + skills_found, + } => { + format!(" searched {source} ({plugin}): {path} → {skills_found} skill(s)") + } + Self::SkillConsidered { + skill, + plugin, + matched, + reason, + } => { + if *matched { + format!(" skill {skill} ({plugin}): included") + } else { + let r = reason.as_deref().unwrap_or("predicates not satisfied"); + format!(" skill {skill} ({plugin}): skipped ({r})") + } + } + Self::SkillInstalled { skill, agent, dest } => { + format!("✅ installed skill {skill} for {agent} → {dest}") + } + Self::SkillRemoved { path } => { + format!("➖ removed {path}") + } + Self::SkillPropagated { skill, agent, dest } => { + format!("✅ propagated skill {skill} for {agent} → {dest}") + } + Self::HookRegistered { agent, hook } => { + format!("🟢 {hook}: hooks registered for {agent}") + } + Self::McpServerRegistered { agent, server } => { + format!("✅ registered MCP server {server} for {agent}") + } + Self::Info { message } => { + format!("ℹ️ {message}") + } + Self::Warning { message } => { + format!("⚠️ {message}") + } + + Self::HookConsidered { + plugin, + hook, + event, + selected, + format, + reason, + } => { + let fmt = format.as_deref().unwrap_or("symposium"); + if *selected { + format!(" hook {hook} ({plugin}): selected for {event} [format={fmt}]") + } else { + let r = reason.as_deref().unwrap_or("not matched"); + format!(" hook {hook} ({plugin}): skipped for {event} ({r})") + } + } + Self::HookDispatched { + plugin, + hook, + exit_code, + error, + } => { + if let Some(err) = error { + format!(" hook {hook} ({plugin}): error — {err}") + } else { + let code = exit_code.unwrap_or(0); + format!(" hook {hook} ({plugin}): exited {code}") + } + } + + Self::Validated { + path, + item_kind, + valid, + error, + warning, + } => { + if *valid { + if let Some(w) = warning { + format!(" ⚠️ {path} ({item_kind}): {w}") + } else { + format!(" ✅ {path} ({item_kind})") + } + } else { + let e = error.as_deref().unwrap_or("unknown error"); + format!(" ✗ {path} ({item_kind}): {e}") + } + } + Self::ProviderListed { + name, + source_type, + url, + path, + plugins, + } => { + let location = url.as_deref().or(path.as_deref()).unwrap_or("(local)"); + let mut lines = format!("Provider: {name}\n Type: {source_type}\n {location}"); + if plugins.is_empty() { + lines.push_str("\n (no plugins)"); + } else { + for p in plugins { + lines.push_str(&format!("\n - {p}")); + } + } + lines + } + } + } +} + +/// Handle returned when creating a report layer, allowing the caller +/// to drain accumulated JSON after the operation completes. +#[derive(Clone)] +pub struct ReportHandle { + buffer: Arc>>, +} + +impl ReportHandle { + /// Drain accumulated JSON events. Only meaningful in `Json` mode. + pub fn drain(&self) -> Vec { + std::mem::take(&mut *self.buffer.lock().unwrap()) + } +} + +/// Tracing layer that captures events with a `report` field. +pub struct ReportLayer { + mode: ReportMode, + buffer: Arc>>, + max_level: tracing::Level, +} + +impl ReportLayer { + pub fn new(mode: ReportMode, max_level: tracing::Level) -> (Self, ReportHandle) { + let buffer = Arc::new(Mutex::new(Vec::new())); + let handle = ReportHandle { + buffer: buffer.clone(), + }; + ( + Self { + mode, + buffer, + max_level, + }, + handle, + ) + } +} + +struct ReportVisitor { + report_json: Option, +} + +impl Visit for ReportVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "report" { + self.report_json = Some(value.to_string()); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "report" { + self.report_json = Some(format!("{value:?}")); + } + } +} + +impl Layer for ReportLayer +where + S: tracing::Subscriber, +{ + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + if event.metadata().level() > &self.max_level { + return; + } + + let mut visitor = ReportVisitor { report_json: None }; + event.record(&mut visitor); + + let Some(json_str) = visitor.report_json else { + return; + }; + + match self.mode { + ReportMode::Normal => { + if let Ok(evt) = serde_json::from_str::(&json_str) { + println!("{}", evt.format_human()); + } + } + ReportMode::Verbose => { + if let Ok(evt) = serde_json::from_str::(&json_str) { + eprintln!("{}", evt.format_human()); + } + } + ReportMode::Json => { + if let Ok(val) = serde_json::from_str::(&json_str) { + self.buffer.lock().unwrap().push(val); + } + } + } + } +} diff --git a/src/skills.rs b/src/skills.rs index 873f9115..d9502563 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -12,6 +12,15 @@ use crate::config::Symposium; use crate::plugins::{ParsedPlugin, PluginRegistry, PluginSource, SkillGroup}; use crate::predicate::{self, Predicate, PredicateSet}; +fn source_display(source: &PluginSource) -> String { + match source { + PluginSource::None => "none".into(), + PluginSource::Path(p) => format!("path:{}", p.display()), + PluginSource::Git(url) => format!("git:{url}"), + PluginSource::Crate => "crate".into(), + } +} + /// A parsed skill from a SKILL.md file. #[derive(Debug, Clone)] pub struct Skill { @@ -168,9 +177,24 @@ pub async fn skills_applicable_to( let plugin = &parsed.plugin; // First check if plugin applies to these crates if !plugin.applies_to_crates(&for_crates) { + tracing::debug!( + report = %crate::report::ReportEvent::PluginConsidered { + plugin: plugin.name.clone(), + matched: false, + reason: Some("plugin-level crate predicates not satisfied".into()), + }, + ); continue; } + tracing::debug!( + report = %crate::report::ReportEvent::PluginConsidered { + plugin: plugin.name.clone(), + matched: true, + reason: None, + }, + ); + for group in &plugin.skills { let (group_crates, skills) = load_skills_for_group(sym, parsed, group, workspace_crates, &for_crates).await; @@ -179,6 +203,7 @@ pub async fn skills_applicable_to( collect_skill_applicable_to( &skill, origin, + &plugin.name, &plugin.crates, &group_crates, &for_crates, @@ -190,11 +215,21 @@ pub async fn skills_applicable_to( // Standalone skills already carry their own `SkillOrigin` (computed // from the plugin source name and the skill's path within that source). + if !registry.standalone_skills.is_empty() { + tracing::debug!( + report = %crate::report::ReportEvent::PluginConsidered { + plugin: "(standalone skills)".into(), + matched: true, + reason: None, + }, + ); + } let empty = PredicateSet { predicates: vec![] }; for entry in ®istry.standalone_skills { collect_skill_applicable_to( &entry.skill, entry.origin.clone(), + "(standalone skills)", &empty, &empty, &for_crates, @@ -236,6 +271,22 @@ async fn load_skills_for_group( // Pre-fetch filtering: skip groups whose crate predicates don't match any target. if !group_crates.predicates.is_empty() && !group_crates.matches(for_crates) { tracing::debug!(plugin = %plugin_path.display(), "skill group crates don't match, skipping"); + let crates_display = group_crates + .predicates + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "); + tracing::debug!( + report = %crate::report::ReportEvent::SkillGroupConsidered { + plugin: plugin.name.clone(), + group_crates: Some(crates_display), + source: Some(source_display(&group.source)), + matched: false, + skills_found: None, + reason: Some("group crate predicates not satisfied".into()), + }, + ); return (group_crates, Vec::new()); } @@ -256,13 +307,26 @@ async fn load_skills_for_group( let dir = plugin_dir.join(p); load_path_skills(&dir, group, parsed) } - PluginSource::None => { - // No source — nothing to discover. Kept distinct from the - // path branch so we don't synthesize a bogus skill dir. - Vec::new() - } + PluginSource::None => Vec::new(), }; + let crates_display = group_crates + .predicates + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "); + tracing::debug!( + report = %crate::report::ReportEvent::SkillGroupConsidered { + plugin: plugin.name.clone(), + group_crates: if crates_display.is_empty() { None } else { Some(crates_display) }, + source: Some(source_display(&group.source)), + matched: true, + skills_found: Some(skills.len()), + reason: None, + }, + ); + (group_crates, skills) } @@ -374,9 +438,17 @@ async fn fetch_and_resolve_skills( match metadata { None => { - // No metadata section → fall back to default `skills/` subdirectory. let dir = result.path.join(crate::plugins::CRATE_DEFAULT_SKILLS_PATH); - for skill_result in discover_skills(&dir, group) { + let discovered = discover_skills(&dir, group); + tracing::debug!( + report = %crate::report::ReportEvent::SkillSourceSearched { + plugin: format!("crate:{crate_name}"), + source: format!("crate_path:{}", crate::plugins::CRATE_DEFAULT_SKILLS_PATH), + path: dir.display().to_string(), + skills_found: discovered.iter().filter(|r| r.is_ok()).count(), + }, + ); + for skill_result in discovered { match skill_result { Ok(skill) => skills.push((skill, origin.clone())), Err(e) => tracing::warn!( @@ -388,12 +460,20 @@ async fn fetch_and_resolve_skills( } } Some(meta) => { - // Metadata present — process each entry. for source in &meta.skills { match source { crate::crate_metadata::SkillSource::Path(p) => { let dir = result.path.join(p); - for skill_result in discover_skills(&dir, group) { + let discovered = discover_skills(&dir, group); + tracing::debug!( + report = %crate::report::ReportEvent::SkillSourceSearched { + plugin: format!("crate:{crate_name}"), + source: format!("crate_path:{p}"), + path: dir.display().to_string(), + skills_found: discovered.iter().filter(|r| r.is_ok()).count(), + }, + ); + for skill_result in discovered { match skill_result { Ok(skill) => skills.push((skill, origin.clone())), Err(e) => tracing::warn!( @@ -438,8 +518,17 @@ async fn load_git_skills( let Some((cache_dir, source, commit_sha)) = fetch_git_skill_source(sym, url).await else { return Vec::new(); }; + let discovered = discover_skills(&cache_dir, group); + tracing::debug!( + report = %crate::report::ReportEvent::SkillSourceSearched { + plugin: source.repo_id(), + source: format!("git:{url}"), + path: cache_dir.display().to_string(), + skills_found: discovered.iter().filter(|r| r.is_ok()).count(), + }, + ); let mut skills = Vec::new(); - for result in discover_skills(&cache_dir, group) { + for result in discovered { match result { Ok(skill) => { let skill_path = skill_path_within_repo(&cache_dir, &skill.path, source.subpath()); @@ -467,8 +556,17 @@ fn load_path_skills( group: &SkillGroup, parsed: &ParsedPlugin, ) -> Vec<(Skill, SkillOrigin)> { + let discovered = discover_skills(dir, group); + tracing::debug!( + report = %crate::report::ReportEvent::SkillSourceSearched { + plugin: parsed.plugin.name.clone(), + source: format!("path:{}", dir.strip_prefix(&parsed.source_dir).unwrap_or(dir).display()), + path: dir.display().to_string(), + skills_found: discovered.iter().filter(|r| r.is_ok()).count(), + }, + ); let mut skills = Vec::new(); - for result in discover_skills(dir, group) { + for result in discovered { match result { Ok(skill) => { let origin = SkillOrigin::Source { @@ -702,6 +800,7 @@ fn load_skill(skill_md_path: &Path, group: &SkillGroup) -> Result { fn collect_skill_applicable_to( skill: &Skill, origin: SkillOrigin, + plugin_name: &str, plugin_crates: &PredicateSet, group_crates: &PredicateSet, for_crates: &[(String, semver::Version)], @@ -727,8 +826,25 @@ fn collect_skill_applicable_to( }; if !entry.matches_workspace(for_crates) { + tracing::debug!( + report = %crate::report::ReportEvent::SkillConsidered { + skill: skill.name().to_string(), + plugin: plugin_name.to_string(), + matched: false, + reason: Some("skill-level crate predicates not satisfied".into()), + }, + ); return; } + + tracing::debug!( + report = %crate::report::ReportEvent::SkillConsidered { + skill: skill.name().to_string(), + plugin: plugin_name.to_string(), + matched: true, + reason: None, + }, + ); results.push(entry); } diff --git a/src/sync.rs b/src/sync.rs index 3984e428..5ad215f9 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -130,23 +130,21 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { /// /// Leaves `dest_dir` alone if it exists and lacks the `.symposium` marker — /// the user put something there by hand and we must not clobber it. -fn propagate_user_skill( - source_dir: &Path, - dest_dir: &Path, - project_root: &Path, - out: &Output, -) -> Result { +fn propagate_user_skill(source_dir: &Path, dest_dir: &Path, project_root: &Path) -> Result { if dest_dir == source_dir { - // Agent reads from the same directory as the source — nothing to do. return Ok(false); } let target_is_managed = has_symposium_marker(dest_dir); if dest_dir.exists() && !target_is_managed { - out.warn(format!( - "skipping propagation to {}: user-managed skill already present", - display_path(dest_dir) - )); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!( + "skipping propagation to {}: user-managed skill already present", + display_path(dest_dir) + ), + }, + ); return Ok(false); } @@ -168,7 +166,8 @@ fn propagate_user_skill( /// Run the full sync: discover applicable skills, install into agent dirs, /// clean up stale installations. -pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { +pub async fn sync(sym: &Symposium, cwd: &Path) -> Result<()> { + let out = &Output::quiet(); let project_root = crate::init::find_workspace_root(sym, cwd)?; tracing::debug!(root = %project_root.display(), "resolved workspace root"); @@ -177,17 +176,18 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { let workspace = crate::crate_sources::workspace_crates(&project_root); for warning in ®istry.warnings { - out.warn(format!( - "skipping {}: {}", - display_path(&warning.path), - warning.message - )); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("skipping {}: {}", display_path(&warning.path), warning.message), + }, + ); } - out.info(format!( - "scanning {} workspace dependencies", - workspace.len() - )); + tracing::info!( + report = %crate::report::ReportEvent::Info { + message: format!("scanning {} workspace dependencies", workspace.len()), + }, + ); // Find all applicable skills let applicable = skills::skills_applicable_to(sym, ®istry, &workspace).await; @@ -244,7 +244,11 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { ); if agent_names.is_empty() { - out.info("no agents configured, run `cargo agents init` to add one"); + tracing::info!( + report = %crate::report::ReportEvent::Info { + message: "no agents configured, run `cargo agents init` to add one".into(), + }, + ); return Ok(()); } @@ -289,27 +293,38 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { // Create the destination (and any missing parents) with a `*` gitignore // in each new directory. if let Err(e) = create_managed_dir_all(&dest_dir, &project_root) { - out.warn(format!("failed to create {}: {e}", display_path(&dest_dir))); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to create {}: {e}", display_path(&dest_dir)), + }, + ); continue; } match agent.install_skill(skill_source, &dest_dir) { Ok(()) => { - // Mark the directory as symposium-managed (marker + - // wildcard .gitignore). Kept as a warning on failure so - // a broken install doesn't halt the whole sync. if let Err(e) = mark_generated_skill_directory(&dest_dir) { - out.warn(format!("failed to mark {}: {e}", display_path(&dest_dir))); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to mark {}: {e}", display_path(&dest_dir)), + }, + ); } installed_dirs.insert(dest_dir.clone()); - tracing::info!(skill = %dir_name, agent = %agent_name, dest = %dest_dir.display(), "installed skill"); - out.done(format!( - "installed skill {dir_name} → {}", - display_path(&dest_dir) - )); + tracing::info!( + report = %crate::report::ReportEvent::SkillInstalled { + skill: dir_name.clone(), + agent: agent_name.clone(), + dest: display_path(&dest_dir), + }, + ); } Err(e) => { - out.warn(format!("failed to install skill {dir_name}: {e}")); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to install skill {dir_name}: {e}"), + }, + ); } } } @@ -335,26 +350,24 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { None => continue, }; let dest_dir = agent.project_skill_dir(&project_root, name); - match propagate_user_skill(source_dir, &dest_dir, &project_root, out) { + match propagate_user_skill(source_dir, &dest_dir, &project_root) { Ok(true) => { installed_dirs.insert(dest_dir.clone()); tracing::info!( - skill = %name, - agent = %agent_name, - dest = %dest_dir.display(), - "propagated skill from .agents/skills/" + report = %crate::report::ReportEvent::SkillPropagated { + skill: name.to_string(), + agent: agent_name.clone(), + dest: display_path(&dest_dir), + }, ); - out.done(format!( - "propagated skill {name} → {}", - display_path(&dest_dir) - )); } Ok(false) => {} Err(e) => { - out.warn(format!( - "failed to propagate skill {name} to {}: {e}", - display_path(&dest_dir) - )); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to propagate skill {name} to {}: {e}", display_path(&dest_dir)), + }, + ); } } } @@ -384,14 +397,18 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { } match fs::remove_dir_all(&path) { Ok(()) => { - tracing::info!(path = %path.display(), "removed stale skill"); - out.removed(format!("removed {}", display_path(&path))); + tracing::info!( + report = %crate::report::ReportEvent::SkillRemoved { + path: display_path(&path), + }, + ); } Err(e) => { - out.warn(format!( - "failed to remove stale {}: {e}", - display_path(&path) - )); + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to remove stale {}: {e}", display_path(&path)), + }, + ); } } } @@ -406,8 +423,11 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { } if to_install.is_empty() { - tracing::debug!("no applicable skills for workspace dependencies"); - out.info("no applicable skills found for workspace dependencies"); + tracing::info!( + report = %crate::report::ReportEvent::Info { + message: "no applicable skills found for workspace dependencies".into(), + }, + ); } Ok(()) diff --git a/symposium-testlib/Cargo.toml b/symposium-testlib/Cargo.toml index e4527655..d911088e 100644 --- a/symposium-testlib/Cargo.toml +++ b/symposium-testlib/Cargo.toml @@ -19,3 +19,5 @@ sacp = "11" sacp-tokio = "11" tokio = { version = "1", features = ["macros"] } toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "registry"] } diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 05e1a583..218a25a4 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -253,6 +253,32 @@ impl TestContext { Ok(out.captured().join("\n")) } + /// Run `cargo agents sync` with a report layer and return the captured + /// report events as JSON values. The `level` controls verbosity: + /// `tracing::Level::INFO` for install/remove only, `tracing::Level::DEBUG` + /// for the full decision trace. + pub async fn sync_with_report( + &mut self, + level: tracing::Level, + ) -> anyhow::Result> { + use symposium::report::{ReportLayer, ReportMode}; + use tracing_subscriber::layer::SubscriberExt; + + let (layer, handle) = ReportLayer::new(ReportMode::Json, level); + let subscriber = tracing_subscriber::registry().with(layer); + let _guard = tracing::subscriber::set_default(subscriber); + + let cwd = self + .workspace_root + .clone() + .unwrap_or_else(|| self.sym.config_dir().to_path_buf()); + + symposium::sync::sync(&self.sym, &cwd).await?; + + drop(_guard); + Ok(handle.drain()) + } + /// Run the full hook pipeline: parse → builtin → plugins → serialize. /// /// This is what `symposium hook ` does, minus stdin/stdout. diff --git a/tests/help_render.rs b/tests/help_render.rs index 07efe80f..34d07874 100644 --- a/tests/help_render.rs +++ b/tests/help_render.rs @@ -34,6 +34,8 @@ async fn cargo_agents_help_lists_plugin_vended() { Options: --update Control plugin source update behavior (none, check, fetch) [default: none] [possible values: none, check, fetch] -q, --quiet Suppress status output + -v, --verbose Print detailed information about decisions made + --json Output structured JSON report -h, --help Print help -V, --version Print version "#]] diff --git a/tests/init_sync.rs b/tests/init_sync.rs index c02e56e4..a5611a9b 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; +use serde_json::Value; use symposium_testlib::{TestMode, with_fixture}; /// Read the user config file from the test context. @@ -2095,3 +2096,132 @@ async fn sync_crate_metadata_missing_path_dir() { .await .unwrap(); } + +// ── Report / verbose output tests ──────────────────────────────────── + +/// `sync_with_report` at INFO level emits SkillInstalled events. +#[tokio::test] +async fn report_json_info_emits_installed_events() { + with_fixture( + TestMode::SimulationOnly, + &["plugins0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let events = ctx.sync_with_report(tracing::Level::INFO).await?; + + assert!(!events.is_empty(), "expected at least one report event"); + + let installed: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "skill_installed") + .collect(); + + assert!( + !installed.is_empty(), + "expected at least one skill_installed event, got: {events:?}" + ); + assert_eq!(installed[0]["skill"], "serde-guidance"); + assert_eq!(installed[0]["agent"], "claude"); + assert!( + installed[0]["dest"] + .as_str() + .unwrap() + .contains("serde-guidance") + ); + + // At INFO level, no plugin_considered or skill_considered events + let considered: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "plugin_considered" || e["kind"] == "skill_considered") + .collect(); + assert!( + considered.is_empty(), + "INFO level should not include considered events, got: {considered:?}" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `sync_with_report` at DEBUG level includes decision-trace events. +#[tokio::test] +async fn report_json_debug_emits_decision_events() { + with_fixture( + TestMode::SimulationOnly, + &["plugins0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; + + // Should have skill_considered for serde-guidance (matched) + let skill_matched: Vec<&Value> = events + .iter() + .filter(|e| { + e["kind"] == "skill_considered" + && e["matched"] == true + && e["skill"] == "serde-guidance" + }) + .collect(); + assert!( + !skill_matched.is_empty(), + "expected skill_considered matched event for serde-guidance, got: {events:#?}" + ); + + // Should also have skill_installed + let installed: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "skill_installed") + .collect(); + assert!( + !installed.is_empty(), + "expected skill_installed events at DEBUG level too" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// When workspace doesn't satisfy a skill's predicates, debug report shows +/// that skill as skipped. +#[tokio::test] +async fn report_json_shows_skipped_skills() { + with_fixture( + TestMode::SimulationOnly, + &["plugins0", "workspace-empty0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; + + // The plugins0 fixture has a serde-guidance skill that requires `serde`. + // workspace-empty0 has no deps, so it should be skipped. + let skipped: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "skill_considered" && e["matched"] == false) + .collect(); + assert!( + !skipped.is_empty(), + "expected at least one skill to be skipped when workspace has no deps" + ); + + // No skill_installed events should appear + let installed: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "skill_installed") + .collect(); + assert!( + installed.is_empty(), + "expected no skill_installed events for empty workspace, got: {installed:?}" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} diff --git a/tests/subcommand_dispatch.rs b/tests/subcommand_dispatch.rs index c4eba7cd..81398faf 100644 --- a/tests/subcommand_dispatch.rs +++ b/tests/subcommand_dispatch.rs @@ -57,6 +57,8 @@ async fn help_shows_plugin_subcommand() { Options: --update Control plugin source update behavior (none, check, fetch) [default: none] [possible values: none, check, fetch] -q, --quiet Suppress status output + -v, --verbose Print detailed information about decisions made + --json Output structured JSON report -h, --help Print help -V, --version Print version "#]]