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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The ledger record schema is versioned separately (`schema_version`, see

## [Unreleased]

### Added

- **`provio log --json`**: one JSON object per session (JSON Lines:
`session_id`, `records`, `denied`, `approved_by_human`) for scripts and CI.
The default table is unchanged. (#14)

## [0.1.5] — 2026-10-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ the same in all three interception modes, and so is the record.
|---|---|
| `provio run -- <agent>` | wrap an agent process under policy |
| `provio proxy --mcp --server <name> -- <cmd>` | govern every call to an MCP server |
| `provio log` · `provio show <call-id>` | what did my agent actually do last night |
| `provio log [--json]` · `provio show <call-id>` | what did my agent actually do last night (`--json`: one session per line, for scripts) |
| `provio verify` | is this ledger still the one that was written |
| `provio replay <session> --candidate <file>` | what would this policy have done to last week's run |
| `provio policy test` | unit-test rules against recorded fixtures |
Expand Down
11 changes: 10 additions & 1 deletion crates/provio-cli/src/cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,10 @@ fn summarize(args: &serde_json::Value) -> String {
}
}
/// `provio log` — the answer to "what did my agent do last night".
pub fn log(ledger: &Path) -> Result<()> {
///
/// With `--json`, prints one [`SessionSummary`] per line (JSON Lines) and
/// nothing else, so scripts can pipe it into `jq` without parsing the table.
pub fn log(ledger: &Path, json: bool) -> Result<()> {
if !ledger_present(ledger) {
bail!(
"no ledger at {} — nothing recorded yet",
Expand All @@ -347,6 +350,12 @@ pub fn log(ledger: &Path) -> Result<()> {
}
let sessions: Vec<SessionSummary> =
provio_ledger::sessions(ledger).map_err(|e| anyhow!(e.to_string()))?;
if json {
for s in &sessions {
println!("{}", serde_json::to_string(s)?);
}
return Ok(());
}
let total: u64 = sessions.iter().map(|s| s.records).sum();
let denied: u64 = sessions.iter().map(|s| s.denied).sum();
let human: u64 = sessions.iter().filter(|s| s.approved_by_human).count() as u64;
Expand Down
8 changes: 6 additions & 2 deletions crates/provio-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ enum Commands {
},

/// What did my agent actually do?
Log,
Log {
/// Print one JSON object per session (JSON Lines) instead of the table.
#[arg(long)]
json: bool,
},

/// One decision, in full.
Show { call_id: String },
Expand Down Expand Up @@ -307,7 +311,7 @@ fn main() -> anyhow::Result<()> {
Commands::Mcp { sub } => mcp_pins::run(&ledger, &sub),
Commands::Ui(args) => ui::serve(&policy, &ledger, cli.yolo, &args),
Commands::Receipt { sub } => receipt::run(&policy, &ledger, &sub),
Commands::Log => cmds::log(&ledger),
Commands::Log { json } => cmds::log(&ledger, json),
Commands::Show { call_id } => cmds::show(&ledger, &call_id),
Commands::Verify => cmds::verify(&ledger),
Commands::Policy { sub } => match sub {
Expand Down
91 changes: 91 additions & 0 deletions crates/provio-cli/tests/log_json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! `provio log --json`: one JSON object per session, for scripts and CI.

mod check_common;

use check_common::*;
use serde_json::{json, Value};

fn pre(session: &str, id: &str, command: &str) -> Value {
json!({
"session_id": session,
"turn_id": "turn-1",
"transcript_path": null,
"cwd": "/work/proj",
"hook_event_name": "PreToolUse",
"model": "gpt-5-codex",
"permission_mode": "default",
"tool_name": "Bash",
"tool_input": { "command": command },
"tool_use_id": id,
})
}

fn stdout(out: &std::process::Output) -> String {
assert!(
out.status.success(),
"provio failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}

fn session<'a>(sessions: &'a [Value], needle: &str) -> &'a Value {
sessions
.iter()
.find(|s| s["session_id"].as_str().unwrap().contains(needle))
.unwrap_or_else(|| panic!("no session {needle} in {sessions:?}"))
}

#[test]
fn log_json_prints_one_object_per_session() {
let p = Project::new();
p.hook("codex", &[], &pre("thr-a", "c1", "ls"));
p.hook("codex", &[], &pre("thr-a", "c2", "echo hi"));
p.hook("codex", &[], &pre("thr-b", "c3", "rm -rf /"));

let text = stdout(&p.provio(&["log", "--json"], ""));
let sessions: Vec<Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("not JSON: {l:?}: {e}")))
.collect();
assert_eq!(sessions.len(), 2, "{text}");

// Every line is a full session summary and nothing else (no table header).
for s in &sessions {
let keys: Vec<&str> = s.as_object().unwrap().keys().map(String::as_str).collect();
assert_eq!(keys.len(), 4, "unexpected fields in {s}: {keys:?}");
assert!(s["session_id"].is_string() && s["records"].is_u64());
assert!(s["denied"].is_u64() && s["approved_by_human"].is_boolean());
}

assert_eq!(session(&sessions, "thr-a")["denied"], 0);
assert_eq!(session(&sessions, "thr-b")["denied"], 1);
assert_eq!(session(&sessions, "thr-b")["approved_by_human"], false);

// The per-session counts add up to the ledger.
let total: u64 = sessions
.iter()
.map(|s| s["records"].as_u64().unwrap())
.sum();
assert_eq!(total as usize, p.records().len());
}

#[test]
fn log_without_json_keeps_the_table() {
let p = Project::new();
p.hook("codex", &[], &pre("thr-a", "c1", "ls"));
let text = stdout(&p.provio(&["log"], ""));
let mut lines = text.lines();
assert!(lines.next().unwrap().contains("1 sessions"), "{text}");
assert!(lines.next().unwrap().starts_with("session"), "{text}");
assert!(serde_json::from_str::<Value>(text.lines().last().unwrap()).is_err());
}

#[test]
fn log_json_without_a_ledger_fails_like_the_table() {
let p = Project::new();
let out = p.provio(&["log", "--json"], "");
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("no ledger"));
assert!(out.stdout.is_empty());
}
2 changes: 1 addition & 1 deletion docs/INTERFACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,4 @@ Specified in full in [receipts.md](receipts.md#contract-7--receipts-provio_recei

## CLI surface (`provio-cli`)

`provio init [--agent A,B] [--strict] [--global] [--dry-run]` · `provio scan [--days N] [--agent claude-code,codex,gemini] [--packs A,B] [--dir PATH] [--format text|json|markdown]` · `provio test [--tool T --path P --url U --query Q --content C --server S | --call JSON] [--packs A,B] [--json] [<command>…]` (exit 0 allow/redact, 2 deny, 3 ask, 1 error) · `provio run [--yolo] [--policy PATH] [--net open|none] [--allow-write PATH]… [--unconfined] [--no-hooks] -- <agent cmd>` · `provio check [--stdio] [--format provio|claude-code|codex|gemini|cursor|windsurf] [--ask deny|defer|ui] [--if-no-policy deny|starter]` · `provio integrate <claude-code|codex|gemini|cursor|windsurf>` · `provio ui [--port N] [--no-open]` · `provio receipt keygen|create|verify|prove|anchor` · `provio proxy --mcp [--transport stdio|http --listen ADDR --upstream URL] [--no-pin]` · `provio mcp pins [--server S]|accept <server> [tool…]|reset <server>` · `provio log` · `provio show <call-id>` · `provio verify [--ledger PATH]` · `provio replay <run-id>` · `provio policy test` · `provio policy add <pack>` · `provio doctor` · `provio report [--format html|markdown|json] [--since 12h|RFC3339] [--session ID] [--sign KEY] [--out PATH|-]`. Ledger default path: `.provio/ledger.jsonl` (displayed as `ledger.db` once SQLite is enabled).
`provio init [--agent A,B] [--strict] [--global] [--dry-run]` · `provio scan [--days N] [--agent claude-code,codex,gemini] [--packs A,B] [--dir PATH] [--format text|json|markdown]` · `provio test [--tool T --path P --url U --query Q --content C --server S | --call JSON] [--packs A,B] [--json] [<command>…]` (exit 0 allow/redact, 2 deny, 3 ask, 1 error) · `provio run [--yolo] [--policy PATH] [--net open|none] [--allow-write PATH]… [--unconfined] [--no-hooks] -- <agent cmd>` · `provio check [--stdio] [--format provio|claude-code|codex|gemini|cursor|windsurf] [--ask deny|defer|ui] [--if-no-policy deny|starter]` · `provio integrate <claude-code|codex|gemini|cursor|windsurf>` · `provio ui [--port N] [--no-open]` · `provio receipt keygen|create|verify|prove|anchor` · `provio proxy --mcp [--transport stdio|http --listen ADDR --upstream URL] [--no-pin]` · `provio mcp pins [--server S]|accept <server> [tool…]|reset <server>` · `provio log [--json]` (JSON Lines: one session summary per line) · `provio show <call-id>` · `provio verify [--ledger PATH]` · `provio replay <run-id>` · `provio policy test` · `provio policy add <pack>` · `provio doctor` · `provio report [--format html|markdown|json] [--since 12h|RFC3339] [--session ID] [--sign KEY] [--out PATH|-]`. Ledger default path: `.provio/ledger.jsonl` (displayed as `ledger.db` once SQLite is enabled).
Loading