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
13 changes: 11 additions & 2 deletions .github/workflows/_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# release.yml (production repos) and test-release.yml (the shared test repo),
# which supply `dist_repo` and `record`.
#
# Always: bump each of the agent's plugin manifest versions
# (.<agent>-plugin/plugin.json), build, and `make publish` to <dist_repo>.
# Always: build and `make publish` to <dist_repo>. Claude and Codex releases
# also bump each plugin manifest version. Antigravity's native manifest has no
# version field, so its repository tag is the release version.
# When record=true (production): commit the bump to main when needed, tag
# v<version>-<plugin>, and create a GitHub Release on the monorepo. After the
# distribution is deployed, its repo gets an unsuffixed v<version> tag and
Expand Down Expand Up @@ -93,6 +94,7 @@ jobs:
fi

- name: Bump plugin manifest versions
if: ${{ inputs.plugin != 'antigravity' }}
run: python3 scripts/set-plugin-version.py "${{ inputs.plugin }}" "${{ steps.vars.outputs.version }}"

- name: Record monorepo release
Expand Down Expand Up @@ -175,3 +177,10 @@ jobs:
with:
dist_repo: ${{ inputs.dist_repo }}
secrets: inherit

smoke-antigravity:
needs: release
if: ${{ inputs.plugin == 'antigravity' }}
uses: ./.github/workflows/smoke-antigravity.yml
with:
dist_repo: ${{ inputs.dist_repo }}
16 changes: 9 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Release a plugin to its PRODUCTION distribution repo:
# claude -> braintrustdata/braintrust-claude-plugin
# codex -> braintrustdata/braintrust-codex-plugin
# antigravity -> braintrustdata/braintrust-antigravity-plugin
# claude -> braintrustdata/braintrust-claude-plugin
# codex -> braintrustdata/braintrust-codex-plugin
#
# Full flow: stamp the version -> commit to main when needed -> monorepo
# tag/release using v<version>-<plugin> -> deploy -> distribution-repo
# tag/release using v<version>. For a safe dry run, use test-release.yml
# Full flow: stamp manifest versions where the agent format supports them ->
# commit to main when needed -> monorepo tag/release using
# v<version>-<plugin> -> deploy -> distribution-repo tag/release using
# v<version>. For a safe dry run, use test-release.yml
# (deploys to the sandbox repo and skips all commits, tags, and releases).

name: Release plugin
Expand All @@ -20,7 +22,7 @@ on:
description: "Plugin bundle to release"
required: true
type: choice
options: [claude, codex]
options: [antigravity, claude, codex]

permissions:
contents: write
Expand All @@ -31,6 +33,6 @@ jobs:
with:
version: ${{ inputs.version }}
plugin: ${{ inputs.plugin }}
dist_repo: ${{ inputs.plugin == 'codex' && 'braintrustdata/braintrust-codex-plugin' || 'braintrustdata/braintrust-claude-plugin' }}
dist_repo: ${{ inputs.plugin == 'antigravity' && 'braintrustdata/braintrust-antigravity-plugin' || inputs.plugin == 'codex' && 'braintrustdata/braintrust-codex-plugin' || 'braintrustdata/braintrust-claude-plugin' }}
record: true
secrets: inherit
44 changes: 44 additions & 0 deletions .github/workflows/smoke-antigravity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Smoke Antigravity distribution

on:
workflow_call:
inputs:
dist_repo:
description: "Distribution repository to install"
required: true
type: string

permissions:
contents: read

jobs:
install:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Install Antigravity CLI
run: |
curl -fsSL https://antigravity.google/cli/install.sh | bash
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Install published plugin
env:
DIST_REPO: ${{ inputs.dist_repo }}
run: |
set -euo pipefail
agy plugin install "https://github.com/$DIST_REPO"
agy plugin validate "$HOME/.gemini/config/plugins/braintrust-antigravity-tracing"
agy plugin list | jq -e '
.imports[]
| select(
.name == "braintrust-antigravity-tracing"
and .source == "antigravity"
and (.components | index("hooks")) != null
)
'

- name: Uninstall published plugin
run: |
set -euo pipefail
agy plugin uninstall braintrust-antigravity-tracing
test ! -e "$HOME/.gemini/config/plugins/braintrust-antigravity-tracing"
2 changes: 1 addition & 1 deletion .github/workflows/test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:
description: "Plugin bundle to release"
required: true
type: choice
options: [claude, codex]
options: [antigravity, claude, codex]

permissions:
contents: write
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Build output
dist/

# Local credentials and environment overrides
.env
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ repository that its marketplace installs from.
## Layout

```text
src/plugins/<agent>/ one directory per agent (claude, codex)
src/plugins/<agent>/ one directory per agent
content/ the deployable plugin tree, verbatim
build.sh <dir> assemble the deployable tree into <dir>
validate.sh <dir> validate manifests and required files
Expand Down Expand Up @@ -41,12 +41,15 @@ cargo test --manifest-path bt-daemon/Cargo.toml --all-features

## Versioning and distribution

Versioning is per plugin. Each plugin carries its version in its plugin
manifest, and `scripts/set-plugin-version.py` updates those manifests for a
release. Marketplace manifests are not versioned.
Versioning is per distribution. Claude and Codex plugins carry their version in
their plugin manifests, and `scripts/set-plugin-version.py` updates those
manifests for a release. Antigravity's schema does not expose a version field,
so its distribution is versioned by repository tags and GitHub Releases.
Marketplace manifests are not versioned.

| Agent | Distribution repository |
|---|---|
| antigravity | `braintrustdata/braintrust-antigravity-plugin` |
| claude | `braintrustdata/braintrust-claude-plugin` |
| codex | `braintrustdata/braintrust-codex-plugin` |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ For further instructions, see the instructions for your desired coding agent

| Agent | distribution repository |
|-------------|-------------------------|
| Google Antigravity | [braintrustdata/braintrust-antigravity-plugin](https://github.com/braintrustdata/braintrust-antigravity-plugin) |
| Claude Code | [braintrustdata/braintrust-claude-plugin](https://github.com/braintrustdata/braintrust-claude-plugin) |
| Codex | [braintrustdata/braintrust-codex-plugin](https://github.com/braintrustdata/braintrust-codex-plugin) |
| OpenCode | npm: [`@braintrust/trace-opencode`](https://www.npmjs.com/package/@braintrust/trace-opencode) |
Expand Down
16 changes: 16 additions & 0 deletions bt-daemon/src/command_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ mod tests {
assert!(!rendered.contains("installed for"));
}

#[test]
fn disable_output_is_explicit() {
let output = TraceCommandOutput::disable(
"antigravity",
"Google Antigravity",
PathBuf::from("/tmp/antigravity/braintrust.json"),
);
let value: serde_json::Value =
serde_json::from_str(&output.render(OutputFormat::Json).unwrap()).unwrap();
assert_eq!(value["command"], "disable");
assert!(output
.render(OutputFormat::Human)
.unwrap()
.contains("removed for Google Antigravity"));
}

#[test]
fn stop_json_reports_idempotent_and_successful_shutdowns() {
let absent: serde_json::Value = serde_json::from_str(
Expand Down
72 changes: 71 additions & 1 deletion bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ pub struct HookArgs {
/// Explicit event name (overrides `--event-field` lookup).
#[arg(long)]
pub event: Option<String>,
/// JSON field holding a transcript path. When present, capture the file
/// length observed by this hook so deterministic journal replay cannot
/// read transcript records written by later lifecycle events.
#[arg(long)]
pub transcript_path_field: Option<String>,
/// Fail instead of spawning a daemon if none is running.
#[arg(long)]
pub no_spawn: bool,
Expand Down Expand Up @@ -237,7 +242,11 @@ pub async fn run_hook(
if !settings.tracing_enabled() {
return Ok(());
}
let payload = read_stdin_json()?;
let mut payload = read_stdin_json()?;

if let Some(field) = &args.transcript_path_field {
add_transcript_observation(&mut payload, field);
}

let session_id = json_str_field(&payload, &args.session_id_field)
.ok_or_else(|| anyhow::anyhow!("no `{}` field in hook payload", args.session_id_field))?;
Expand Down Expand Up @@ -1018,6 +1027,47 @@ fn json_str_field(payload: &serde_json::Value, field: &str) -> Option<String> {
}
}

/// Stamp the transcript boundary visible when a blocking hook runs. Agent
/// transcripts are append-only, while daemon journal replay may happen after
/// the session has advanced. Recording byte lengths keeps translation causally
/// aligned with each native hook without copying transcript contents into the
/// journal.
fn add_transcript_observation(payload: &mut serde_json::Value, field: &str) {
let Some(path) = json_str_field(payload, field) else {
return;
};
let transcript = std::path::Path::new(&path);
let mut observation = serde_json::Map::new();
observation.insert("path".into(), serde_json::Value::String(path.clone()));
if let Ok(metadata) = std::fs::metadata(transcript) {
observation.insert(
"observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}

if transcript.file_name().and_then(|name| name.to_str()) == Some("transcript.jsonl") {
let full = transcript.with_file_name("transcript_full.jsonl");
if let Ok(metadata) = std::fs::metadata(&full) {
observation.insert(
"full_path".into(),
serde_json::Value::String(full.to_string_lossy().into_owned()),
);
observation.insert(
"full_observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}
}

if let Some(object) = payload.as_object_mut() {
object.insert(
"_bt_transcript_observation".into(),
serde_json::Value::Object(observation),
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1127,6 +1177,26 @@ mod tests {
assert!(now_ms() > 0);
}

#[test]
fn transcript_observation_captures_compact_and_full_boundaries() {
let dir = tempfile::tempdir().unwrap();
let compact = dir.path().join("transcript.jsonl");
let full = dir.path().join("transcript_full.jsonl");
std::fs::write(&compact, b"compact\n").unwrap();
std::fs::write(&full, b"complete record\n").unwrap();
let mut payload = serde_json::json!({
"transcriptPath": compact.to_string_lossy()
});

add_transcript_observation(&mut payload, "transcriptPath");

let observed = &payload["_bt_transcript_observation"];
assert_eq!(observed["path"], compact.to_string_lossy().as_ref());
assert_eq!(observed["observed_bytes"], 8);
assert_eq!(observed["full_path"], full.to_string_lossy().as_ref());
assert_eq!(observed["full_observed_bytes"], 16);
}

#[test]
fn import_destination_without_session_config_fails_fast() {
let mut config = None;
Expand Down
17 changes: 17 additions & 0 deletions bt-daemon/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ pub const SOCKET_ENV: &str = "BT_DAEMON_SOCKET";
pub const DATA_DIR_ENV: &str = "BT_DAEMON_DATA_DIR";
/// Env override for the current agent's non-credential tracing settings file.
pub const SETTINGS_ENV: &str = "BT_DAEMON_CONFIG";
/// Env override for Antigravity's native configuration directory. Primarily
/// useful for isolated validation and managed environments.
pub const ANTIGRAVITY_CONFIG_DIR_ENV: &str = "BT_ANTIGRAVITY_CONFIG_DIR";

fn home() -> PathBuf {
std::env::var_os("HOME")
Expand Down Expand Up @@ -99,10 +102,24 @@ pub fn agent_settings_path(source: &str, explicit: Option<&Path>) -> PathBuf {
.join("opencode")
.join("braintrust.json"),
"pi" => home().join(".pi").join("agent").join("braintrust.json"),
"antigravity" => home()
.join(".gemini")
.join("config")
.join("braintrust.json"),
other => data_dir(None).join("agents").join(format!("{other}.json")),
}
}

/// Resolve Antigravity's native configuration directory.
pub(crate) fn antigravity_config_dir() -> PathBuf {
if let Some(path) = std::env::var_os(ANTIGRAVITY_CONFIG_DIR_ENV) {
if !path.is_empty() {
return PathBuf::from(path);
}
}
home().join(".gemini").join("config")
}

/// Create `dir` (and parents) mode 0700 on unix.
pub fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
Expand Down
Loading
Loading