// blog

Blocking vs. nitpicking: shaping review tone with `OUTPUT.md`.

Encoding team norms into the review output. Severity scales, finding format, what gets surfaced and what gets quietly suppressed.

·

Two engineers can hold the same review standards and still produce wildly different review experiences. One leads every comment with “this might be wrong but…”; the other lands every comment as an imperative. One groups findings by file; the other groups them by severity. One never writes nits; the other writes them but flags them as optional. Same rules, different cultures.

OUTPUT.md is where CommitBrief lets you encode that culture. It’s a deliberate split from COMMITBRIEF.md: rules are what you’re looking for, format is how the findings come back. Most teams have stronger consensus on the first than on the second, and the design respects that asymmetry.

What OUTPUT.md is (and what it isn’t)

OUTPUT.md is a Go text/template that runs locally, on the parsed findings JSON the provider returned. It never travels in the prompt. The model decides what to flag (driven by COMMITBRIEF.md); your template decides how that flag is rendered when --markdown or --output <file>.md is the destination. Cards (the default TTY view) and --json ignore OUTPUT.md entirely.

That distinction matters because it bounds what OUTPUT.md can do. It can re-order findings, group them, change the severity labels you display, drop low-severity rows, add a header, embed a count — anything mechanical you can compute from the typed Finding shape:

type Finding struct {
    Severity    string  // "critical" | "high" | "medium" | "low" | "info"
    File        string
    Line        int
    LineEnd     int
    Title       string
    Description string
    Suggestion  string
    Language    string
    Snippet     string
}

It cannot ask the model to write differently. The “tone” OUTPUT.md shapes is the tone of the local markdown — the chrome around findings, not the findings themselves.

A minimal template

{{ $bucketed := groupBySeverity .Findings }}
# Review summary

Files touched: {{ countFiles .Findings }}

{{ with index $bucketed "critical" }}
## Critical
{{ range . }}
- **{{ .File }}:{{ .Line }}** — {{ .Title }}
  {{ .Description }}
  > {{ .Suggestion }}
{{ end }}
{{ end }}

{{ with index $bucketed "high" }}
## High
{{ range . }}
- **{{ .File }}:{{ .Line }}** — {{ .Title }}
{{ end }}
{{ end }}

The decision in that template is the choice that matters: critical findings get the full body (title, description, suggestion); high findings get a one-line summary; medium and below are silently dropped. That’s a cultural call. Some teams want everything. Some want only the things that should block merge. OUTPUT.md makes both possible without anyone arguing about which is the “right” default.

Available helpers in the template surface: upper, lower, groupBySeverity, countFiles. Plus everything in the standard text/template library — range, if, with, len, printf, the lot.

The three-tier fallback

OUTPUT.md is resolved through a three-tier chain:

  1. Repo-local: ./.commitbrief/OUTPUT.md — if present, this wins. Typically used by teams that want a project-specific output convention.
  2. User-global: ~/.commitbrief/OUTPUT.md — your personal default, applied to any repo without a project-local version.
  3. Embedded default: ships in the binary, gives reasonable output even when neither of the above exists.

This chain is the same shape as the COMMITBRIEF.md fallback for rules content, with one important difference: OUTPUT.md is intentionally a per-user preference, not a team artifact. The repo-local path lives under ./.commitbrief/, which is gitignored by default (the setup --local wizard auto-adds it). The personal “show me no nits” preference stays on your machine; it doesn’t get committed and force itself on teammates.

This is the design choice I want to defend in this post. Mixing team norms with personal preferences in the same file is the source of half the friction in code review culture.

Why split COMMITBRIEF.md from OUTPUT.md?

The split maps to a real distinction:

  • COMMITBRIEF.md answers what should we look for in a review? This is a team question. The team agrees on the rules; the rules are committed; the file lives at the repo root and travels to the model as the system prompt.
  • OUTPUT.md answers how do I want findings reported to me? This is a person question. I might want terse output; you might want detailed explanations. I might suppress lows; you might want them as a learning signal.

If both lived in the same file, every personal preference change would be a commit, every commit would be a team discussion, and the discussions about format would crowd out the discussions about rules. By splitting them, the team owns one file (committed to git) and individuals own the other (gitignored).

The two also occupy different layers in the pipeline. COMMITBRIEF.md is the system prompt; the model reads it and decides what to flag. OUTPUT.md is a renderer; it runs after the response is parsed, on your machine, against the structured findings. Pre-v0.6.0 these layers were merged — OUTPUT.md used to be a natural-language instruction embedded in the prompt — but the merged design produced too many ways for personal-preference language to drift into rule-shaping language. Splitting them into two layers made each layer’s job obvious.

Pre-send validation

If your custom OUTPUT.md is malformed (parse error, missing field reference, panic-on-execute), CommitBrief catches it before any provider call. Three checks run on load:

  1. Parse — text/template syntax check.
  2. Execute against an empty []Finding{} — guards against templates that crash when there are no findings.
  3. Execute against a synthetic sample of one finding per severity — guards against templates that crash on specific severities.

A failure aborts the run with a pointer at the file and a hint: run 'commitbrief init --yes' to overwrite it with the default. No tokens are spent on a review you couldn’t render.

A few patterns from the wild

From users sharing their templates:

  • The pragmatic skip — drop findings below high entirely. Most popular; cuts review noise dramatically.

    {{ range .Findings }}{{ if or (eq .Severity "critical") (eq .Severity "high") }}
    - **{{ upper .Severity }}** {{ .File }}:{{ .Line }} — {{ .Title }}
    {{ end }}{{ end }}
  • The minimal mode — one line per finding. Used by senior engineers who want a fast scan and don’t need the explanation.

    {{ range .Findings }}{{ .File }}:{{ .Line }} [{{ upper .Severity }}] {{ .Title }}
    {{ end }}
  • The teaching mode — every field rendered, with the suggestion blockquoted as the actionable line. Popular with engineers mentoring juniors, who want the model’s reasoning visible.

    {{ range .Findings }}
    ### {{ upper .Severity }} — {{ .File }}:{{ .Line }}
    **{{ .Title }}**
    
    {{ .Description }}
    
    > {{ .Suggestion }}
    {{ end }}
  • The grouped reportgroupBySeverity plus per-bucket sections. Good when you regularly pipe --markdown --output review.md into a PR description.

None of these are “right.” They’re different cultural choices, and the file makes them switchable without coordination. To switch output locale, edit output.lang in config (en or tr); the template is locale-agnostic.

What OUTPUT.md does not control

OUTPUT.md shapes how findings are reported in markdown. It doesn’t decide what counts as a finding — that’s COMMITBRIEF.md’s job, unpacked in the anatomy post. It doesn’t decide which files are reviewed — that’s the three-layer filter. It doesn’t choose your provider or model — those are config or per-run flags. It doesn’t apply to the cards renderer (the default TTY view has its own lipgloss-styled layout) or to --json output (the JSON schema is fixed).

A common confusion is to try to filter findings server-side by writing English instructions into OUTPUT.md. That used to work in v0.5.x and earlier; it stopped working in v0.6.0 when the template moved to local execution. If you want to filter what the model flags, edit COMMITBRIEF.md’s “what NOT to flag” section. If you want to filter what your local render shows, use a template if block.

A starting point

If you’ve never written one, copy the embedded default to disk and modify from there:

commitbrief init        # writes COMMITBRIEF.md and the OUTPUT.md template

The init command writes both files because they’re a pair — rules and output shape together. Start by reading the embedded OUTPUT.md once, deciding what bothers you, and editing only those parts. The default is intentionally a middle ground; your job is to bias it toward what your team or your taste prefers.

The next post is about a downstream consequence of all this: how a well-tuned COMMITBRIEF.md becomes the most effective onboarding document a team can have, often more so than the README or the CONTRIBUTING file.

Related reading


← all posts