Configuration reference¶
All runtime config and credentials live in one TOML file. Copy the template
config/env.example.toml
to the gitignored config/env.toml and edit there:
Every default shown below is the value bubo falls back to when a key is omitted —
leaving a key out is the same as setting it to its default. Any string value can
reference environment variables (token = "${GITLAB_TOKEN}",
url = "${GITLAB_URL:-https://gitlab.com}"), so secrets never have to sit on
disk. Each section has its own table and a minimal example; a full copy-paste
quick-start config is at the end.
[scm]¶
| Setting | Default | Purpose |
|---|---|---|
provider |
gitlab |
Source-control backend — gitlab or github. Selects which provider the poller drives. BUBO_PROVIDER=github overrides it for a single run. |
[gitlab]¶
Used when provider = "gitlab".
| Setting | Default | Purpose |
|---|---|---|
url |
https://gitlab.com |
Web host the poller reads MRs from. For self-hosted GitLab, keep api_url on the same host. |
api_url |
https://gitlab.com/api/v4 |
REST API endpoint used by the GitLab MCP tools inside the review agent. |
bot_username |
bubo |
Bot account name on posted threads; lets outcome sync tell bot comments from developer replies. |
denied_tools_regex |
^(delete_.*\|merge_merge_request\|push_files)$ |
Blocks dangerous GitLab MCP tools even if the agent can see them. |
token |
unset | GitLab token with api scope. Exported as GITLAB_TOKEN, GITLAB_PERSONAL_ACCESS_TOKEN, and GLAB_TOKEN. |
[gitlab]
url = "https://gitlab.com"
api_url = "https://gitlab.com/api/v4"
bot_username = "bubo"
token = "${GITLAB_TOKEN}" # api scope; keep the real value in the environment
[github]¶
Used when provider = "github". Needs the gh CLI (checkout) and a GitHub MCP
server on PATH (inline comments; falls back to REST otherwise).
| Setting | Default | Purpose |
|---|---|---|
api_url |
https://api.github.com |
REST API base. Use https://<host>/api/v3 for GitHub Enterprise Server. |
bot_username |
bubo |
Bot account name on review comments; lets outcome sync separate bot comments from replies. |
token |
unset | GitHub token with pull-request read+write. Exported as GITHUB_TOKEN, GITHUB_PERSONAL_ACCESS_TOKEN, and GH_TOKEN. |
[github]
api_url = "https://api.github.com" # https://<host>/api/v3 for GHES
bot_username = "bubo"
token = "${GITHUB_TOKEN}" # pull-request read + write
[review]¶
How the poller picks, paces, and filters reviews.
| Setting | Default | Purpose |
|---|---|---|
dry_run |
true |
Plan findings into SQLite without posting comments. Flip to false once test reviews look right. |
max_merge_requests_per_poll |
8 |
Caps how many MRs one poll cycle queues. Higher values fork more workers at once. |
max_findings_per_merge_request |
8 |
Caps findings per MR and fills {{MAX_FINDINGS_PER_REVIEW}} in the prompt. |
timeout_seconds |
1800 |
Kills a review worker that runs too long. |
min_confidence |
0.85 |
Floor for the LLM's per-finding confidence (0.0–1.0); lower-scored findings are dropped. Inclusive on the high side. |
category_min_confidence |
{} |
Per-canonical-category confidence floors — a finding must clear max(min_confidence, floor). Only ever raises the bar. See Calibrated per-class confidence. |
calibrate_confidence |
false |
Derive per-category floors from this repo's dispute history. Manual floors win. See Calibrated per-class confidence. |
calibrate_max_confidence |
0.97 |
Ceiling for a calibrated floor (kept below 1.0). Only used when calibrate_confidence is on. |
allowed_kinds |
[] |
Whitelist of finding kinds to post — kept if its severity, category, or type matches (case-insensitive). [] = no kind filter. |
tone |
"terse" |
Comment voice — terse / collaborative / socratic / formal / casual. Changes only how a finding reads. See Review-comment tone. |
mode |
"collaborate" |
Surface mode — which findings reach the poster. collaborate (all types) or gate (blocking defects only). See Surface mode. |
suppress_disputed_classes |
false |
Drop category classes this repo repeatedly rejects, using outcome history. See Dispute-driven suppression. |
dispute_suppress_threshold |
0.5 |
Dispute rate (0.0–1.0) at or above which a category is suppressed. Only when suppression is on. |
dispute_suppress_min_samples |
5 |
Minimum recorded outcomes before a category's dispute rate is acted on. Only when suppression is on. |
verify_findings |
false |
Master switch for pre-post verification. See Verification before posting. |
verify_lenses |
["correctness", "in_diff", "reproduce"] |
Lenses run per finding (one LLM call each). |
verify_min_votes |
2 |
Lenses that must vote "real" (≥ floor) for a finding to survive. |
verify_confidence_floor |
0.6 |
Minimum verifier confidence (inclusive) for a real vote to count. |
verify_max_findings |
5 |
Cap on findings verified per change; the rest post unverified. |
verify_timeout_seconds |
300 |
Per-check wall-clock budget. |
verify_command |
[] |
Verifier argv. Empty reuses reviewer_command; set a different model for real diversity. |
[review]
dry_run = true # plan only; flip to false to start posting
max_merge_requests_per_poll = 8
max_findings_per_merge_request = 8
timeout_seconds = 1800
min_confidence = 0.85
allowed_kinds = [] # [] = post everything above min_confidence
tone = "terse" # terse · collaborative · socratic · formal · casual
mode = "collaborate" # or "gate" for a merge-blocking lane
# Opt-in precision levers (all off by default — see the subsections below):
# category_min_confidence = { style = 0.95, design = 0.95, performance = 0.92 }
# calibrate_confidence = false
# suppress_disputed_classes = false
# verify_findings = false
[governance]¶
AI-code provenance capture (opt-in; off by default).
| Setting | Default | Purpose |
|---|---|---|
capture_provenance |
false |
Record a banded AI-provenance signal per change for audit. Capture only — no review-behavior change. See Governance & provenance. |
ai_trailer_patterns |
built-in | Case-insensitive regexes matched against commit-message lines to detect declared AI assistance. Only when capture_provenance is on. |
sensitive_path_globs |
[] |
fnmatch globs flagged when a change touches sensitive paths, e.g. payments/**, *.pem. |
rigor_modulation |
false |
Phase 2. An escalated change gets a heightened-scrutiny prompt directive. Advisory; auto-implies the provenance fetch. |
escalate_bands |
["likely_ai", "collaborative"] |
Provenance bands that escalate. unknown never escalates. |
rigor_require_sensitive |
true |
Escalate only if the change also touches a sensitive_path_globs path; false escalates on band alone. |
policy_mode |
off |
off / report-only / soft. When not off, an auditable decision is recorded per change. All modes advisory. |
[governance]
capture_provenance = false # opt-in; capture only, never blocks a merge
# sensitive_path_globs = ["payments/**", "**/auth/**", "*.pem"]
# rigor_modulation = false # Phase 2 (advisory)
# policy_mode = "off" # off · report-only · soft
[poller]¶
Filesystem layout and scheduling hints.
| Setting | Default | Purpose |
|---|---|---|
state_dir |
var |
Holds SQLite state, logs, reports, worktrees, and rendered prompts. |
interval_seconds |
900 |
Suggested wait for long-running poll loops. Cron/systemd can use another interval. |
target_merge_request_iid |
unset | Single-MR debug filter. Leave unset in production. |
[poller]
state_dir = "var"
interval_seconds = 900
# target_merge_request_iid = 123 # debug only; unset in production
[agents]¶
Review-agent CLI configuration.
| Setting | Default | Purpose |
|---|---|---|
prompt_file |
prompts/00-meta.md |
Meta prompt rendered before each review. |
llm_model |
gpt-5.5 |
Model passed to the review wrapper. Keep [telemetry] pricing aligned for cost metrics. |
llm_api_key |
unset | API key for whatever LLM you review with. Exported as LLM_API_KEY plus the name in llm_api_key_env. |
llm_api_key_env |
OPENAI_API_KEY |
The env-var name your LLM CLI reads the key from — ANTHROPIC_API_KEY (Claude), GEMINI_API_KEY (Gemini), etc. Bubo is model-agnostic and does not guess it. |
reasoning_effort |
medium |
low / medium / high. Higher is more thorough but costs more and runs longer. |
dry_run |
true |
Dry-run hint exported to the agent as REVIEW_DRY_RUN, separate from [review].dry_run (which controls poster posting). |
codex_profile |
bubo |
Codex profile used by the Codex wrapper. |
codex_sandbox |
read-only |
Filesystem access for Codex review runs. See Troubleshooting for the bubblewrap/AppArmor caveat. |
post_no_findings_comment |
true |
Post one change-level "no issues found" ack on a clean review. Dedup'd by bot author + body; honors [review].dry_run. |
no_findings_comment_body |
"Automated review ran — no issues found." |
Body of that ack, posted verbatim. Must be byte-identical across re-reviews for dedup. Empty/whitespace disables it. |
[agents]
prompt_file = "prompts/00-meta.md"
llm_model = "gpt-5.5"
llm_api_key = "${LLM_API_KEY}"
llm_api_key_env = "OPENAI_API_KEY" # ANTHROPIC_API_KEY for Claude, etc.
reasoning_effort = "medium"
codex_profile = "bubo"
codex_sandbox = "read-only"
post_no_findings_comment = true
[telemetry]¶
OpenTelemetry metrics, spans, and cost estimation.
| Setting | Default | Purpose |
|---|---|---|
enabled |
false |
Send OTel metrics and spans. SQLite state is written either way. |
service_name |
bubo |
Service name shown in the OTel backend. Use the same value across a fleet. |
environment |
prod |
Environment label for dashboards, e.g. dev / staging / prod. |
otlp_endpoint |
http://127.0.0.1:4317 |
OTLP/gRPC collector endpoint for metrics and traces. |
otlp_protocol |
grpc |
OTLP transport. Only grpc is supported today (validated at startup). |
export_interval_seconds |
30 |
Metric export interval. Lower is fresher, at more exporter overhead. |
emit_finding_events |
true |
Emit per-finding lifecycle metrics (planned, posted, skipped, resolved). |
emit_outcome_sync |
true |
Emit metrics when outcome sync checks posted-finding status. |
input_per_1m |
5.0 |
Estimated input-token price per million tokens (cost metrics only). |
output_per_1m |
30.0 |
Estimated output-token price per million tokens. |
cached_input_per_1m |
0.5 |
Estimated cached-input price per million tokens. |
[telemetry]
enabled = false # turn on once an OTLP collector is running
service_name = "bubo"
environment = "prod"
otlp_endpoint = "http://127.0.0.1:4317"
otlp_protocol = "grpc"
export_interval_seconds = 30
input_per_1m = 5.0 # keep these aligned with [agents].llm_model
output_per_1m = 30.0
cached_input_per_1m = 0.5
[mcp_server]¶
How bubo-mcp exposes itself. See MCP server for client setup.
| Setting | Default | Purpose |
|---|---|---|
transport |
stdio |
stdio (client spawns the process per session, no network, no auth) or http (long-lived daemon, bearer-token auth required). |
host |
127.0.0.1 |
Bind address for http. 0.0.0.0 exposes it externally — put TLS in front. |
port |
8765 |
Bind port for http. Pick anything free. |
bearer_token |
unset | Required for http; clients must send Authorization: Bearer <token>. Prefer ${VAR} interpolation so it never lands on disk. |
[mcp_server]
transport = "stdio" # or "http" for a long-lived daemon
# host = "127.0.0.1"
# port = 8765
# bearer_token = "${BUBO_MCP_TOKEN}" # required when transport = "http"
[[projects]]¶
One array-of-tables block per repository to review. Add as many as you want; the poller iterates them in order each cycle.
| Setting | Default | Purpose |
|---|---|---|
path |
— | Project path to poll, e.g. group/repo. Sub-groups (group/sub/repo) are supported; do not pre-encode it. |
enabled |
true |
Turns polling for that project on or off without removing the block. |
[[projects]]
path = "group/repo"
enabled = true
[[projects]]
path = "group/another-repo"
enabled = false # kept in config but skipped
Review-comment tone (moods)¶
[review].tone chooses the voice of a posted finding — a pure presentation
choice, entirely yours. Bubo ships with terse, so behavior is unchanged unless
you opt in.
| tone | reads like |
|---|---|
terse (default) |
the structured **Impact:** … **Evidence:** … **Fix:** … render — byte-identical to bubo before this knob existed |
collaborative |
a senior engineer's inline note — acknowledges intent, gives a concrete example, suggests the fix |
socratic |
leads with a question that surfaces the gap and invites confirmation |
formal |
measured, complete sentences, no contractions — for regulated/enterprise review |
casual |
relaxed and brief |
How it works. For any non-terse tone, Bubo injects a short voice
directive into the review prompt (a register description plus one cross-domain
style example) asking the reviewer to add a single in-voice comment field per
finding. Bubo posts that comment in place of the structured render, at the cost
of a few extra output tokens per finding.
What never changes with tone. The structured fields
(title/impact/evidence/fix/confidence) are still recorded to SQLite
exactly as in terse mode, and the dedup fingerprint is computed from those
fields, not the voiced comment. So you can switch tone any time without
re-posting existing findings, splitting their accept/dispute history, or
disturbing the governance dataset — only the words developers read change.
Measuring which mood works. The active tone is recorded per run on the
review_runs row and surfaced in the bubo report audit trail (and its CSV
export), so you can A/B accept-vs-dispute rates by tone before committing your
team to one voice.
The same finding in each tone¶
One real finding (a cookie-deletion bug, captured on a public PR) as each tone would post it — identical severity/evidence/confidence underneath:
terse(default) —**Issue (non-blocking, correctness):** popitem removes duplicate-name cookiesfollowed by the structured**Impact:** … **Evidence:** … **Fix:** … **Confidence:** 0.99block.collaborative— "Heads up — this removes by name only, so if the jar hassidfora.exampleandb.example, onepopitem()returns one pair but deletes both cookies. Probably worth clearing the specific cookie using its domain/path/name."socratic— "What happens here when the jar has the same cookie name for two domains?del self[name]goes throughremove_cookie_by_namewithout domain/path, so this removes every matching cookie while returning only one pair — should we clear the selected cookie by domain/path/name instead?"formal— "When multiple domains contain the same cookie name, this deletes by name only and removes every matching cookie while returning a single pair. Recommend clearing the specific cookie selected bypopitemusing its domain, path, and name."casual— "Quick one — this deletes by name only, so same-name cookies on other domains/paths get cleared too. Grab the Cookie from the iterator and clear that exact domain/path/name."
Surface mode: gate vs collaborate¶
[review].mode is a precision/recall lever — it chooses which findings
reach the poster, orthogonal to tone (which only changes how a surfaced
finding reads). It is off by default (collaborate), and the review prompt
always emits every finding type regardless of mode — mode only filters what
gets posted.
| mode | surfaces | use it for |
|---|---|---|
collaborate (default) |
bubo's full typed output — issue, and the deliberate suggestion/question collaborative modes — across every category |
human-in-the-loop review, where a well-placed question or suggestion is a feature, not noise |
gate |
only blocking-severity defect findings: type = issue, severity in blocking/high/critical, and a canonical category in the defect set |
a CI / merge-blocking bot that must keep false positives near zero |
gate raises precision at the cost of recall (a known trade-off — high-velocity
teams target a <5% false-positive rate, while enterprises still want ≥90%
recall), which is exactly why it is opt-in rather than the default.
Why a mode, not just allowed_kinds. allowed_kinds keeps a finding if
any one of its severity/category/type is whitelisted (an OR). gate is a
conjunction — assertion type and blocking severity and defect category
— which an OR-list cannot express. The two are independent filters: set both and
a finding must satisfy both.
The canonical category taxonomy¶
Models emit wildly inconsistent category strings (logic vs code-logic,
test/testing/test-coverage, documentation vs docs), so gate first
normalizes each finding's category onto a fixed, orthogonal taxonomy before
deciding. The normalized value lives in a separate category_canonical field —
your original label is preserved verbatim in the posted body, the dedup
fingerprint, and the audit row, so turning gate on never re-posts or
re-fingerprints an existing finding.
- Defect (surfaced by
gate):correctness·security·concurrency·resource·error_handling·performance - Non-defect (dropped by
gate, kept bycollaborate):style·docs·test·design·naming·other
Unrecognized labels map to other (a non-defect bucket), so an unknown category
is never silently promoted into the merge gate. Synonyms are folded
automatically — including the review contract's own enum
(failure→error_handling, compatibility→correctness,
maintainability→design, documentation→docs).
On severity. The contract asks for blocking/non-blocking, but real models
also emit high/critical; gate accepts those so a severe defect is not
silently dropped. A finding with no explicit severity does not pass the gate
— a merge gate should block only on a clear high-severity signal.
Audit note. Dispute-driven suppression (below) still keys on the raw
category, by design: its job is to learn which labels your team rejects, so it must see the model's own words, not the normalized bucket.
Calibrated per-class confidence¶
The global min_confidence (0.85) applies one bar to every finding. But a
self-reported 0.9 means very different things for a correctness claim versus a
style nit — weak models are reliably overconfident on trivia (the empirical
run carried a Missing Semicolon at 0.9 and an Unsigned Comparison at 1.0).
[review].category_min_confidence lets you set a higher bar for noise-prone
classes without touching the rest:
[review.category_min_confidence]
style = 0.95
design = 0.95
performance = 0.92
# correctness / security keep the global 0.85
A finding must clear max(min_confidence, its-category-floor); one that clears
the global bar but falls under its category floor is dropped as
confidence_below_category_floor (distinct from the global
confidence_below_threshold, so you can see the per-class gate fire). Keys are
canonical categories (see Surface mode
for the taxonomy); an unknown key is a config error, not a silent no-op. A floor
only ever raises the bar — one set below min_confidence is ignored.
Auto-calibration from dispute history¶
Setting the floors by hand is the deterministic path. With
calibrate_confidence = true, Bubo additionally derives them from this
repo's own accept/dispute signal: a category the team disputes more earns a
higher floor, linearly from min_confidence (0% disputed) up to
calibrate_max_confidence (100% disputed), once a category has at least
dispute_suppress_min_samples resolved outcomes. Manual
category_min_confidence entries win over a derived floor for the same category.
Two deliberate design points:
- Calibration aggregates on the canonical category, even though
dispute-suppression keys on the raw label. That split is intentional:
suppression learns the exact labels your team rejects; calibration needs the
dispute signal un-fragmented, or
test/testing/test-coverageeach look too thin to ever clear the sample gate. The canonical fold sums them. - Calibration is gentler than suppression. Suppression stops a class from posting at all; calibration only raises its confidence bar (capped below 1.0), so a genuinely high-confidence finding in a disputed class still surfaces.
Like suppression, calibration is self-reinforcing — raising a class's floor
reduces its new outcomes, freezing the rate. The escape hatches are operator-
side: lower calibrate_max_confidence, raise dispute_suppress_min_samples, or
turn calibration off. It is off by default.
Dispute-driven suppression¶
Bubo records, per finding, whether the developer accepted or disputed it — the
accept/dispute signal kept in the finding_outcomes table and surfaced by bubo
--sync-outcomes. Dispute-driven suppression turns that history into a
precision lever: when enabled, the poller stops posting finding category
classes a team has repeatedly rejected on a given repo, instead of
re-litigating the same noise on every merge request.
It is off by default ([review].suppress_disputed_classes = false). Reach
for it only after you've built up outcome history and watched a specific
category — say documentation or maintainability — generate persistent,
dismissed noise. The per-finding min_confidence and allowed_kinds filters are
the first-line controls; this is a sharper, repo-learned complement.
It's conservative by design and does nothing on a fresh install. Even with
the flag on, no category is suppressed until enough outcomes accrue to clear both
dispute_suppress_min_samples and dispute_suppress_threshold — so enabling it
early can't silence findings off a thin signal. It stays inert until the data
earns a suppression, which is the right behavior for a noise filter.
How a category gets suppressed¶
For each repo, bubo joins finding_outcomes to review_findings and groups
by normalized category. A category is suppressed when both hold:
- at least
dispute_suppress_min_samples(default5) of its findings have a recorded outcome, and - the dispute rate —
disputed-or-false-positive ÷ total outcomes— is at or abovedispute_suppress_threshold(default0.5).
The denominator is all outcome rows for the category, including rows written
when an outcome-sync check failed (which count as not-disputed). That
deliberately dilutes the rate: the bias is toward under-suppressing, so a
genuinely useful class is never silenced off a thin or noisy signal. Suppressed
findings are dropped before posting and logged with reason
disputed_class_suppressed (visible in the JSON log stream and the
finding_filtered event), so you can always see what got swallowed.
Known limitation: suppression is self-reinforcing¶
Once a category is suppressed, Bubo stops posting it, so no new review_findings
/ finding_outcomes rows accrue for it. Its dispute rate is therefore frozen
at the snapshot that crossed the threshold — there's no organic recovery path if
the team later starts caring about that class again. This is within the feature's
intent (a class the team keeps rejecting should stay gone), but it's a conscious
trade-off, not an accident. The escape hatches are all operator-side:
- raise
dispute_suppress_thresholdordispute_suppress_min_samples, or - set
suppress_disputed_classes = falseto re-post everything and rebuild fresh outcome history.
Verification before posting¶
Off by default. When verify_findings = true, every finding about to be
posted is first re-checked by N independent "is this real?" lenses; a finding a
majority refute is dropped (recorded refuted) instead of posted. Each lens
re-asks a verifier to try to refute the finding from a different angle —
correctness (is it a real defect?), in_diff (does the cited line do what the
finding claims?), and reproduce (can the failure actually occur?) — and answers
with a strict JSON verdict {"real": ..., "confidence": ..., "reason": ...}. A
finding survives only if at least verify_min_votes lenses vote it real at or
above verify_confidence_floor.
The contract is deliberately conservative: an unparseable answer, a missing
real flag, or a missing/garbled confidence resolves to not real / zero
confidence, so uncertainty never sneaks a finding past the floor.
Honesty: this is the weaker, correlated form by default¶
Stated plainly: with the default verify_command (empty → it reuses
[agents].reviewer_command), verification re-asks the same model with
different lens prompts. That shares the original finding's blind spots — the
weaker, correlated form of pre-post verification. The strong form the
literature credits with ~94–98% false-positive elimination is either a hybrid
static-analysis + LLM check or a genuinely different model. The real
diversity payoff lives in pointing verify_command at a different model:
[review]
verify_findings = true
# Reviewer is Codex (the default); verify with a different model for real
# independence.
verify_command = ["claude", "-p"]
Bubo never labels self-refutation as hard-proven "verified" anywhere in the
audit trail or telemetry — a survived finding is recorded as not refuted (the
verified column is 1), and the per-lens vote tally is persisted in
verify_votes so you can see exactly what each lens said.
Cost and the guardrails¶
Verification can fan out to up to verify_max_findings × len(verify_lenses)
extra serial LLM calls per change. That cost is why the feature is off by
default and why it's fenced in:
- It runs only on in-diff, non-duplicate, post-filter survivors — the findings actually about to be posted. Out-of-diff and policy-dropped findings are never verified, so no calls are wasted on findings that would be skipped anyway.
verify_max_findingscaps how many findings are verified per change. Findings past the cap post unverified (and the count is logged) — the cap never silently suppresses a real finding.- Each check gets its own
verify_timeout_secondswall-clock budget. - A check that times out, errors, or returns unparseable output is treated as failed to run, not as a refutation — so a verifier outage can never silently drop every finding. When no lens runs, the finding posts unverified and the event is logged.
- The per-change
verified/refuted/cappedcounts are emitted as averification_summarylog line — there are no silent caps. - Verification re-runs each poll cycle for findings not yet
posted(refuted findings, and everything in dry-run, are re-verified at the same SHA until the SHA changes), soverify_max_findingsis a per-cycle cap and you re-pay the cost each interval until the change moves on. This mirrors how dry-run already re-plans findings every cycle.
The verifier keys (verify_findings, verify_lenses, verify_min_votes,
verify_confidence_floor, verify_max_findings, verify_timeout_seconds,
verify_command) and their defaults are listed in the [review]
table above.
Governance & provenance¶
For regulated and enterprise teams whose governance functions must approve and monitor AI-assisted code, Bubo can capture a per-change provenance signal and keep it on-prem. There's no third party to ship code or audit data to — the whole compliance pitch of a self-hosted, BYO-LLM code reviewer.
This is off by default ([governance].capture_provenance = false) and, in
its current phase, captures only — it doesn't change which findings post,
raise severities, or block anything. Rigor modulation and policy gates are a
later, separately opt-in phase.
To consume the captured provenance and governance decisions as auditable
metrics — a provenance breakdown, accept-vs-dispute rate, noise trend, ROI
proxy, review latency (p50/p95/max/avg seconds over the window),
per-category dispute rates, no-findings acknowledgements,
token/cost rollups, policy-decision stats, and a per-change audit trail —
use the read-only bubo report command (and the matching
get_governance_report MCP tool). See operate.md,
"Governance report".
The report surfaces the same dispute history that drives dispute-driven suppression, so an operator can see the feedback loop, not just have it act silently:
- The
dispute_classessection lists every finding category with recorded outcomes for the project as{category, total, rejected, dispute_rate}, ordered by dispute rate. These are the raw, config-independent stats. - The dedicated
get_dispute_classes(project)MCP tool additionally reads your realdispute_suppress_threshold/dispute_suppress_min_samplesfrom[review]and adds a truthfulwould_suppressflag per category — i.e. whether the class would be suppressed if suppression were enabled. (The flag never uses hardcoded thresholds, so it cannot misreport what your config would actually do; if the config can't be read it falls back to raw stats with no flag.) Thebubo reportCLI path emits the raw stats only. - The
latencysection reports wall-clock review-run latency (finished_at - started_at) over the window. Theacknowledgementsrollup nested inreviewsmakes the{no_findings, success, failed}status counts first-class, mirroringreviews.by_status.
How capture works¶
Bubo already checks out a change before reviewing it, so it reads the change's
commit trailers and records a banded signal onto the review run. The
category is derived from what the commits declare — e.g. a Co-authored-by:
Claude trailer, or an AI-assisted trailer. Patterns match per commit-message
line and the built-in defaults are anchored to trailer shape, so a prose body
that merely mentions AI isn't read as a declaration. The result is one row per
change with band, source, confidence, the matched declaration lines (for
the audit trail), and any matched sensitive_path_globs.
Two honesty rules (deliberate)¶
- A band, never a verdict.
bandis one ofunknown/likely_ai/collaborative. Bubo never emits a binary "this is AI / this is human" label — post-hoc detection is fragile cross-domain, so a banded signal is the honest shape. - Declared ≠ detected, and
unknownis the default. The absence of an AI declaration is not proof of human authorship, so a change with no signal isunknown, neverhuman. Thesourcefield (trailervsdetection) records which kind of evidence produced the band. This phase is deterministic (trailer) only; LLM-based AI detection is deliberately deferred and would surface assource = detection— distinct, so an auditor always knows whether they're looking at a declaration or a guess.
Audit integrity¶
Provenance is computed once per run and persisted write-once — no code path
retroactively rewrites it. The signal also appears in the JSON log stream as a
provenance_captured event and (when telemetry is on) as the
llm_review.provenance metric, labelled by band and source.
Capability boundary (read this)¶
Bubo cannot block a merge — it doesn't own CI or branch protection. It produces auditable data and an advisory signal; acting on that signal (required approvals, merge gates) stays with your pipeline. Capture is the foundation that makes those downstream gates possible, not a gate itself.
Phase 2 — rigor modulation & policy gates (opt-in)¶
Once provenance is captured, two opt-in capabilities use it. Both are advisory and off by default.
A change escalates when its band is in escalate_bands (default
likely_ai + collaborative; unknown never escalates) and — unless
rigor_require_sensitive = false — it also touches a sensitive_path_globs
path. This single predicate is shared by both capabilities, so they always
agree.
- Rigor modulation (
rigor_modulation = true): an escalated change injects a heightened-scrutiny directive (prioritize the security lens) into that change's review prompt. It's prompt context, never a verdict. (Honest limitation: whether the directive measurably improves the review is LLM behavior — Bubo guarantees the directive is injected, not that the model acts on it.) - Policy gate (
policy_mode = report-only | soft): records an auditable, write-once governance decision per change —actionisflag(escalated) orclear— surfaced as agovernance_decisionlog event and thellm_review.governancemetric, and queryable for reporting. There's noenforcemode: every mode is advisory because Bubo cannot block a merge.
Turning on either capability auto-implies the provenance fetch even if
capture_provenance = false — the per-change commit read is what all three
share.
Quick-start config¶
A minimal config/env.toml to get a first review running — GitLab, dry-run on,
one project. Copy it, fill in the two tokens, then bubo doctor and
bubo-poller. Every key not shown falls back to the defaults documented above.
# config/env.toml — minimal GitLab setup. Secrets via ${ENV}; nothing real on disk.
[scm]
provider = "gitlab" # set "github" and use a [github] block instead for PRs
[gitlab]
token = "${GITLAB_TOKEN}" # personal-access token, api scope
[review]
dry_run = true # plan only; flip to false once a real review looks right
[agents]
llm_model = "gpt-5.5"
llm_api_key = "${LLM_API_KEY}"
llm_api_key_env = "OPENAI_API_KEY" # ANTHROPIC_API_KEY for Claude, etc.
[[projects]]
path = "group/repo"
enabled = true