Operate

Operate

Use this page to verify releases, schedule the poller, sync outcomes, generate governance reports, and backfill existing comments.

Trust · Compliance · Provenance

Verify a release

All Bubo releases are signed with cosign and shipped with SLSA provenance, so you can prove an artifact came from Bubo’s release workflow and wasn’t tampered with. To validate a release:

  1. Install cosign and slsa-verifier.
  2. Download the artifact from the releases page with its attestations — each artifact has a matching <file>.bundle (cosign), and the release carries one multiple.intoto.jsonl (SLSA provenance covering every artifact).
  3. Run both checks:
# point this at the file you downloaded (wheel, sdist, deploy bundle, or the SBOM)
ARTIFACT=bubo-1.2.3-py3-none-any.whl
 
# 1. Signature — signed keyless by the release workflow via Sigstore + GitHub OIDC.
cosign verify-blob \
  --bundle "$ARTIFACT.bundle" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --certificate-identity-regexp "^https://github.com/mountainowl/bubo/\.github/workflows/release\.yml@refs/tags/v" \
  "$ARTIFACT"
 
# 2. Provenance — built by that workflow at a real tag (SLSA Build L3).
slsa-verifier verify-artifact "$ARTIFACT" \
  --provenance-path multiple.intoto.jsonl \
  --source-uri github.com/mountainowl/bubo

The same two commands verify the wheel, sdist, deploy bundle, and the SBOM — printing Verified OK for an authentic artifact.

Install

Install Bubo on the host with uv tool install bubo, then bubo init — the per-stack steps live on the recipes page.

Schedule the poller

bubo init does not install cron entries or systemd units. Scheduling is a separate step, so you can run Bubo under whatever fits your host — cron, systemd, Kubernetes CronJob, Nomad.

bubo init materializes three ready-to-install templates under $BUBO_ROOT/deploy/templates/, with {{ROOT}} already substituted — ready for sudo install / systemctl enable. Pick one path below. Both run the same cadence (poll every 15 min, sync outcomes hourly, health probe every 5 min) and ship separate flock files per role (poller / outcome-sync / health) so the roles never collide on a lock.

Cron

# Create the lock dir owned by the runtime user (once).
sudo install -d -o bubo -g bubo -m 0755 /var/run/bubo
 
# Install the cron drop-in. The exact directory is distro-specific:
#   Debian / Ubuntu / RHEL → /etc/cron.d/
#   macOS launchd hosts    → use the systemd path or convert manually
sudo install -m 0644 \
  "$BUBO_ROOT/deploy/templates/bubo.cron" \
  /etc/cron.d/bubo

The cron entries run bubo-poller (poll cycle), bubo-poller --sync-outcomes (outcome grading), and bubo-poller --health (liveness probe) at staggered cadences. Each invocation runs once and exits — there’s no daemon mode, so tight intervals are safe.

systemd

sudo cp "$BUBO_ROOT/deploy/templates/bubo.service" /etc/systemd/system/
sudo cp "$BUBO_ROOT/deploy/templates/bubo.timer"   /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bubo.timer

The service file uses LoadCredential= to inject secrets from /etc/bubo/credentials/, keeping tokens out of config/env.toml on disk. Pair it with TOML env interpolation in the config:

[gitlab]
token = "${GITLAB_TOKEN}"
 
[agents]
llm_api_key = "${LLM_API_KEY}"

Health check

bubo doctor runs four non-mutating checks:

CheckWhat fails it
Workspace dirs (config/, var/state, var/work, var/log)Init never ran, or the install root moved without re-init.
config/env.toml presentOperator hasn’t seeded the config yet.
SQLite DB initializedSchema-init step skipped or DB file deleted.
~/.codex/config.toml contains [profiles.bubo]Codex profile missing or hand-edited away.

Pass --no-agent-config to skip generated agent profiles and manage them yourself. Doctor returns 0 on a full pass and non-zero on any failure, so it drops cleanly into a cron-driven liveness probe.

Troubleshooting

Host and infrastructure issues — sandbox failures, AppArmor / user-namespace errors, and “no findings” results that are not a genuine clean review — live on a dedicated page: Troubleshooting.

Posting & thread resolution

The poller drives both GitLab and GitHub, but each platform posts inline comments and reads thread resolution differently.

GitHub

  • Posting goes through the GitHub REST API — a pull-request review comment anchored to the diff line. No SCM MCP server or extra CLI is involved.
  • Thread resolution (--sync-outcomes) reads GitHub’s GraphQL reviewThreads (isResolved), since REST doesn’t expose per-thread state. If GraphQL is unavailable, it falls back to a resolution-blind REST path that still records posted/deleted/replied transitions.

GitLab

  • Posting goes through the GitLab REST API — a merge-request discussion anchored to the diff line. No SCM MCP server or extra CLI is involved.
  • Thread resolution needs no GraphQL — GitLab’s REST discussions API already carries the resolved / resolvable flags, so --sync-outcomes reads accurate state (including merged_unresolved) straight from REST.

Outcome sync

--sync-outcomes grades posted findings against current SCM state. It runs from the cron line bubo init materializes, so once scheduling is set up you never call it by hand.

bubo-poller --sync-outcomes

It records whether each finding was resolved, left unresolved after merge, deleted, replied to, marked disputed, marked false-positive, or marked duplicate — feeding the llm_review.findings{status=…} counter described in telemetry.md.

Reply classification

A thread can be resolved because the developer fixed the finding or because they rejected it (“working as intended”, “not a blocker”). The resolved flag alone can’t tell them apart, which would overcount the reviewer’s precision. So when a finding’s discussion has a developer reply and no explicit dispute marker, --sync-outcomes asks an LLM to read the bot’s finding plus the reply and decide whether it was accepted or rejected. A rejection sets disputed (and false_positive when the reply says the finding is factually wrong).

  • Model-agnostic. Classification runs the same agent you set in [agents].reviewer_command (default codex exec --profile bubo; a custom command like claude -p is reused as-is). The review contract lives in the prompt, not the command, so the same command makes the accept/reject call here.
  • Classified once. Each finding is classified one time (tracked by the reply_classified column) to bound LLM cost across the hourly sync. A reply that arrives after the first classification isn’t re-graded.
  • Cold-start safe. At most a few dozen findings are classified per run, so the first sync after upgrading — when the whole backlog of resolved-with-reply findings is unclassified — won’t fire hundreds of agent calls at once. The backlog drains over later runs.
  • Fail-safe. A transient classifier failure (timeout, non-zero exit) leaves the finding unclassified and retries on a later sync; unparseable output degrades to “unclear”. Either way the sync keeps going.

Prefer the explicit path? Tag a reply with [llm-review:disputed] / [llm-review:false-positive] — an explicit marker short-circuits the LLM call.

Governance report

bubo report assembles an auditable governance report from the metrics already in SQLite: review counts (with a no-findings acknowledgements rollup), a provenance breakdown (counts by band/source), the accept-vs-dispute rate, a noise trend (daily false-positives), a bug-catch ROI proxy, review latency (p50/p95/max/avg seconds), per-category dispute rates, token/cost rollups, policy-decision stats (from the Phase 2 governance_decisions), and a per-change audit trail. It’s the compliance-facing companion to the provenance + governance-decision capture in configuration.md, “Governance & provenance”.

It is strictly read-only — it queries existing state, never mutates it, so it’s safe to run from a monitoring cron alongside bubo doctor. The data comes from your own SQLite and never leaves your infrastructure.

# Full nested report as JSON (the default).
bubo report
 
# A week of the per-change audit trail as CSV.
bubo report --since-hours 168 --format csv > audit.csv

Flags

FlagDefaultWhat it does
--format {json,csv}jsonOutput format. JSON is the full nested report; CSV is a single tabular section.
--sectionauditCSV only — which section to emit. audit (the per-change audit trail), noise_trend, or dispute_classes. Ignored for JSON.
--since-hours24Rolling window, in hours, for the report.
--since / --untilunsetISO dates bounding a fixed audit window. Override --since-hours for a reproducible window.
--projectallRestrict the report to a single project path.
--limitunsetCap the number of audit-trail rows.
--root$BUBO_ROOTWorkspace root to read state from (same meaning as elsewhere).

JSON vs CSV

  • JSON (default) is the full nested report. Sections: meta, reviews (with a nested acknowledgements rollup), provenance, outcomes, noise_trend, roi, latency, dispute_classes, policy_decisions, and audit. Scalar rollups (review counts, accept/dispute rate, ROI proxy, latency, token/cost) are JSON-only — they have no tabular form.
  • CSV is a single tabular section, picked with --section. The default is the per-change audit trail; --section noise_trend emits the daily false-positive trend, and --section dispute_classes the per-category dispute rates (CLI path: raw stats, no would_suppress). Use CSV for spreadsheet/BI import of the row-shaped sections.

MCP tool

The same report is available to MCP-capable chat clients (Codex, Claude Desktop, Cline) as get_governance_report(since_hours, since, until, project). It returns the same nested JSON as bubo report --format json, so a governance analyst can pull the report into a chat session without shell access.

A companion get_dispute_classes(project) returns just the per-category dispute stats (cumulative across all recorded outcomes, so it takes no window argument). Unlike the CLI path, it reads your real [review].dispute_suppress_threshold / dispute_suppress_min_samples and adds a would_suppress flag per category, so an analyst can see which classes suppression would drop if enabled.

Policy-decision stats are populated only when Phase 2 governance is enabled ([governance].policy_mode not off). When it’s off, the policy_decisions section reports available: false rather than empty counts, so an auditor can tell “no decisions recorded” apart from “governance decisions were never turned on”.

Backfill — one-time recovery, not a scheduled job

The backfill commands import bot comments that already exist on the SCM into local SQLite. Reach for them when:

  • You just deployed against a project where the bot has posted before from another install.
  • You reset var/state/reviewer.sqlite (test, rebuild, host migration) and need the per-finding metrics to reflect history, not just go-forward.

They are deliberately off the cron schedule. Each run scans every MR/PR updated since the cutoff — suitable for one-time recovery, inefficient as a frequent poll task. Run them once, then let --sync-outcomes take over for go-forward grading.

# GitLab
bubo-poller --backfill-gitlab-bot-comments-since 2026-05-25T00:00:00Z
 
# GitHub
BUBO_PROVIDER=github bubo-poller --backfill-github-bot-comments-since 2026-05-25T00:00:00Z
 
# Then grade the imported rows once:
bubo-poller --sync-outcomes

Both backfill commands are idempotent — a comment already in SQLite is upserted, not duplicated — so re-running with a different cutoff is safe.

MountainOwlMountainOwl
Bubo · MIT licensed · © 2026