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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Prov captures the prompt-and-conversation context behind AI-agent-driven edits, attaches it to commits via git notes, and exposes it through thin read surfaces:

- **CLI** for humans — `prov log src/auth.ts:42` returns the originating prompt for any line.
- **Agent skills and hooks** for supported harnesses — Claude Code and Codex can capture provenance today, and agents can query their prior reasoning before proposing edits.
- **Agent skills and hooks** for supported harnesses — Claude Code and Codex capture provenance automatically during sessions, and an agent skill teaches the agent to answer the user's provenance questions ("why does this do X", "what was the prompt that wrote this", "what's the history of this file") on demand.

## How it works

Expand Down Expand Up @@ -55,6 +55,14 @@ prov log src/auth.ts:42 # the originating prompt for one line
prov search "rate limiting" # find prompts that mention rate limiting
```

Optional: install the agent skill so Claude Code (or any harness that supports Anthropic-style Skills) can answer provenance questions directly in the session — "why does this line do X", "what's the history of this file", "is this drifted":

```bash
npx skills add mattfogel/prov
```

The skill is independent of the capture hooks above — install it per-repo or globally as you prefer; see [Vercel's `skills` CLI](https://github.com/vercel-labs/skills) for flags.

Codex project-local hooks require Codex to trust the repo's `.codex/` config layer; review the installed hooks via `/hooks` before they run.

By default, notes stay on your machine. Opt in to team sharing per-remote:
Expand All @@ -71,7 +79,8 @@ Pre-1.0. The on-disk note format is stable; the CLI surface and config keys may

- [`crates/prov-core`](crates/prov-core) — library: git wrapping, notes I/O, SQLite cache, redactor, transcript parsers.
- [`crates/prov-cli`](crates/prov-cli) — the `prov` binary and subcommands.
- [`plugin/`](plugin) — Claude Code plugin (skill + hooks manifest).
- [`agent-hooks/`](agent-hooks) — Claude Code capture-hook bundle, embedded into the `prov` binary at build time.
- [`skills/`](skills) — the optional Anthropic-style agent skill (install via `npx skills add mattfogel/prov`).
- [`codex/`](codex) — Codex hooks adapter.

## Contributing
Expand Down
44 changes: 44 additions & 0 deletions agent-hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# agent-hooks/ — Claude Code capture hooks

`agent-hooks/hooks.json` is the Claude Code hook bundle that prov uses to
capture each turn's prompt and tool calls during a session. The file is
embedded into the `prov` binary at compile time and written into a repo's
`.claude/settings.json` by `prov install --agent claude`.

Four hooks, each with a 5-second timeout:

- `SessionStart` → `prov hook session-start` — capture the active model.
- `UserPromptSubmit` → `prov hook user-prompt-submit` — stage the prompt.
- `PostToolUse` (matched on `Edit|Write|MultiEdit`) → `prov hook post-tool-use`
— stage the edit.
- `Stop` → `prov hook stop` — mark the turn complete.

The hooks only write to `<git-dir>/prov-staging/`; nothing they emit reaches
the agent's prompt. The staged content is flushed into a git note on
`refs/notes/prov` by the `post-commit` git hook (also installed by
`prov install`).

## Install

```bash
prov install --agent claude
```

That merges these entries into `.claude/settings.json` in the current repo
(idempotent — re-run safely after upgrades). Restart Claude Code so the
hooks reload.

## Read surface

The optional skill at [`../skills/prov/`](../skills/prov) teaches Claude
Code (or any harness that supports Anthropic-style Skills) to answer user
questions about provenance using `prov log` and `prov search`. Install it
separately with [Vercel's `skills` CLI](https://github.com/vercel-labs/skills):

```bash
npx skills add mattfogel/prov
```

The skill is independent of these hooks — capture works fine without the
skill, and the skill works fine in any repo where the `prov` binary is on
`PATH` (whether or not these hooks are installed locally).
File renamed without changes.
40 changes: 6 additions & 34 deletions crates/prov-cli/src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
//! - Optionally adds prov's agent-harness hook entries to repo-local adapter config.
//! - Initializes `<git-dir>/prov.db` and runs an initial reindex.
//!
//! `--plugin` prints the Claude Code marketplace install command
//! (`/plugin install prov`) and exits without modifying the project's
//! `.claude/`. The plugin assumes the `prov` binary is on `PATH`.
//! `--enable-push <REMOTE>` opts into team mode by adding the notes-tracking
//! fetch refspec for the named remote. The pre-push gate that R6 promises
//! ships in U8; until then `--enable-push` documents itself as "fetch only".
Expand Down Expand Up @@ -47,9 +44,9 @@ const PRE_PUSH_TEMPLATE: &str = include_str!("../../../../githooks/pre-push");
/// pre-rewrite commit would orphan when git replaced the SHA.
const POST_REWRITE_TEMPLATE: &str = include_str!("../../../../githooks/post-rewrite");

/// Embedded plugin/hooks/hooks.json so `--plugin`-less installs can mirror the
/// plugin's hook entries into project-scope `.claude/settings.json`.
const PLUGIN_HOOKS_JSON: &str = include_str!("../../../../plugin/hooks/hooks.json");
/// Embedded `agent-hooks/hooks.json` — the Claude Code capture-hook entries
/// merged into project-scope `.claude/settings.json` by `--agent claude`.
const CLAUDE_HOOKS_JSON: &str = include_str!("../../../../agent-hooks/hooks.json");

/// Embedded Codex hook template. Source: `codex/hooks/hooks.json`.
const CODEX_HOOKS_JSON: &str = include_str!("../../../../codex/hooks/hooks.json");
Expand All @@ -61,10 +58,6 @@ pub const HOOK_BLOCK_END: &str = "# <<< prov";

#[derive(Parser, Debug)]
pub struct Args {
/// Print the (currently pre-v1) plugin install instructions and exit
/// without modifying the repo.
#[arg(long)]
pub plugin: bool,
/// Enable team-mode sync at install time (configures the fetch refspec for
/// the named remote). Defaults to local-only — sync is opt-in per-repo.
#[arg(long, value_name = "REMOTE")]
Expand All @@ -89,11 +82,6 @@ enum AgentAdapter {
}

pub fn run(args: Args) -> anyhow::Result<()> {
if args.plugin {
print_plugin_instructions();
return Ok(());
}

let cwd = std::env::current_dir().context("could not read current directory")?;
let git = Git::discover(&cwd).map_err(|e| match e {
prov_core::git::GitError::NotARepo => anyhow!("not in a git repo"),
Expand Down Expand Up @@ -187,22 +175,6 @@ fn adapters_label(adapters: &[AgentAdapter]) -> String {
.join(", ")
}

fn print_plugin_instructions() {
println!("Claude Code plugin install:");
println!();
println!(" 1. Install the prov binary so it's on PATH:");
println!(" cargo install prov");
println!(" # or: brew install mattfogel/tap/prov");
println!(" # or: curl -fsSL https://raw.githubusercontent.com/mattfogel/prov/main/install.sh | sh");
println!();
println!(" 2. Inside Claude Code, run:");
println!(" /plugin install prov");
println!();
println!(" (Note: `prov install --plugin` does not modify this project's");
println!(" .claude/ directory. Use `prov install` without --plugin for the");
println!(" per-repo install path that wires hooks and config locally.)");
}

// -------- git config --------

fn configure_git(git: &Git) -> anyhow::Result<()> {
Expand Down Expand Up @@ -312,12 +284,12 @@ pub(crate) fn claude_settings_path(git: &Git) -> PathBuf {
}

fn install_claude_settings(git: &Git) -> anyhow::Result<()> {
let plugin_hooks: Value = serde_json::from_str(PLUGIN_HOOKS_JSON)
.context("embedded plugin/hooks/hooks.json failed to parse")?;
let plugin_hooks: Value = serde_json::from_str(CLAUDE_HOOKS_JSON)
.context("embedded agent-hooks/hooks.json failed to parse")?;
let plugin_hooks_obj = plugin_hooks
.get("hooks")
.and_then(Value::as_object)
.ok_or_else(|| anyhow!("embedded plugin hooks JSON missing top-level `hooks` object"))?
.ok_or_else(|| anyhow!("embedded agent-hooks/hooks.json missing top-level `hooks` object"))?
.clone();

let path = claude_settings_path(git);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
//! U11 plugin-layout tests.
//! Agent-hooks bundle layout tests.
//!
//! Validates the on-disk shape of `plugin/` against the documented Claude
//! Code plugin schema, plus the behavioral guarantee that
//! `prov install --plugin` exits without mutating the project's `.claude/`.
//! Validates the on-disk shape of `agent-hooks/` — the directory whose
//! `hooks.json` is embedded by `prov install --agent claude` into a repo's
//! `.claude/settings.json`. The previous shape lived under `plugin/` and
//! carried a Claude Code plugin manifest; the manifest is gone, but the
//! hooks bundle itself is still load-bearing and worth lint-testing.

use std::path::{Path, PathBuf};

Expand All @@ -17,8 +19,8 @@ fn workspace_root() -> PathBuf {
.to_path_buf()
}

fn plugin_dir() -> PathBuf {
workspace_root().join("plugin")
fn agent_hooks_dir() -> PathBuf {
workspace_root().join("agent-hooks")
}

fn read_json(path: &Path) -> Value {
Expand All @@ -31,37 +33,8 @@ fn read_json(path: &Path) -> Value {
}

#[test]
fn plugin_manifest_has_required_fields() {
let manifest = plugin_dir().join(".claude-plugin").join("plugin.json");
let value = read_json(&manifest);
let obj = value
.as_object()
.expect("plugin.json must be a JSON object");

// The Claude Code plugin schema requires `name` at minimum; we additionally
// require `description` and `version` so the marketplace listing has
// enough metadata to render usefully.
for required in ["name", "description", "version"] {
assert!(
obj.contains_key(required),
"plugin.json is missing required field `{required}`"
);
assert!(
obj[required].is_string() && !obj[required].as_str().unwrap().is_empty(),
"plugin.json field `{required}` must be a non-empty string"
);
}

assert_eq!(
obj["name"].as_str().unwrap(),
"prov",
"plugin name must be `prov` (matches binary name and marketplace install command)"
);
}

#[test]
fn plugin_hooks_register_all_four_events() {
let hooks_path = plugin_dir().join("hooks").join("hooks.json");
fn agent_hooks_register_all_four_events() {
let hooks_path = agent_hooks_dir().join("hooks.json");
let value = read_json(&hooks_path);
let hooks = value
.get("hooks")
Expand Down Expand Up @@ -119,7 +92,7 @@ fn plugin_hooks_register_all_four_events() {
Some(*command),
"event `{event}` command mismatch"
);
// Plan specifies a 5-second timeout for every capture hook.
// 5-second timeout for every capture hook.
assert_eq!(
nested_hook.get("timeout").and_then(Value::as_i64),
Some(5),
Expand All @@ -129,14 +102,10 @@ fn plugin_hooks_register_all_four_events() {
}

#[test]
fn plugin_readme_exists() {
let readme = plugin_dir().join("README.md");
fn agent_hooks_readme_exists() {
let readme = agent_hooks_dir().join("README.md");
assert!(
readme.exists(),
"plugin/README.md must exist so marketplace listings have install instructions"
"agent-hooks/README.md must exist so the directory is self-explanatory"
);
}

// Behavioral coverage for `prov install --plugin` not mutating `.claude/`
// lives in `cli_read.rs::install_plugin_flag_does_not_touch_repo` — the U5
// install tests own that fixture setup and we don't duplicate it here.
14 changes: 0 additions & 14 deletions crates/prov-cli/tests/cli_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,20 +395,6 @@ fn install_heals_legacy_top_level_command_shape() {
assert_eq!(stop_arr[0]["hooks"][0]["command"], "prov hook stop");
}

#[test]
fn install_plugin_flag_does_not_touch_repo() {
let tmp = init_repo();
prov_in(tmp.path())
.args(["install", "--plugin"])
.assert()
.success()
.stdout(predicate::str::contains("/plugin install prov"));

assert!(!tmp.path().join(".git/hooks/post-commit").exists());
assert!(!tmp.path().join(".claude/settings.json").exists());
assert!(!tmp.path().join(".codex/hooks.json").exists());
}

#[test]
fn install_enable_push_configures_fetch_refspec() {
let tmp = init_repo();
Expand Down
6 changes: 3 additions & 3 deletions crates/prov-cli/tests/cli_skill_layout.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! U12 SKILL content lints.
//! SKILL content lints.
//!
//! Validates the on-disk shape of `plugin/skills/prov/`: frontmatter has the
//! Validates the on-disk shape of `skills/prov/`: frontmatter has the
//! required keys, the body fits under the 500-line cap, the two reference
//! docs exist and are linked from `SKILL.md`, and the manual smoke-test plan
//! is committed alongside.
Expand All @@ -16,7 +16,7 @@ fn workspace_root() -> PathBuf {
}

fn skill_dir() -> PathBuf {
workspace_root().join("plugin").join("skills").join("prov")
workspace_root().join("skills").join("prov")
}

/// Splits a SKILL.md-style file into (frontmatter, body). Frontmatter is the
Expand Down
19 changes: 0 additions & 19 deletions plugin/.claude-plugin/plugin.json

This file was deleted.

82 changes: 0 additions & 82 deletions plugin/README.md

This file was deleted.

Loading