Why the CLI is bin/codeotter.mjs and not an npm package
A CI reviewer has to install in environments you do not control: GitHub Actions runners, Forgejo Actions pods, ephemeral K8s pods, air-gapped runners with no npm registry access, and self-hosted Docker containers with read-only filesystems. The brand promise is zero per-seat billing — and that means zero install tax too. If a step pulls npm install codeotter-cli it inherits the entire npm dependency closure (and a registry outage vector) into your CI.
So bin/codeotter.mjs is a single zero-dependency Node.js file. It uses only node:child_process, node:fs, node:path, and node:readline. It auto-loads .env from the current directory and the repo root via process.loadEnvFile if Node ≥ 20.6. It calls out to gh for the pr subcommand and to git for diffs and outside-impact symbol grep. It calls fetch() against the model provider endpoints. That is the entire dependency closure.
You vendor the file into your repo (one cp or one wget), call it from CI as node bin/codeotter.mjs ci --base origin/main --fail-on-gate, and you are done. There is no daemon, no service, no background process. The file terminates with the review it ran.
The ci subcommand and the --fail-on-gate contract
The CLI exposes three subcommands:
codeotter review # Review local git diff (default if no command given) codeotter pr <url-or-number> # Review and score a GitHub / Forgejo / Gitea PR codeotter ci # CI gate checks with merge blocker exit codes
ci is review plus the gate-fail exit-code logic. Specifically, the CLI runs runReview(diff, flags) against the diff (default: working tree; with --staged: staged; with --base <branch>: git diff <branch>...HEAD), then checks:
if (flags.failOnGate || command === "ci") {
const failedGates = result.gates.filter((g) => !g.pass);
const lowScore = result.scores.quality < flags.minScore;
if (failedGates.length > 0 || lowScore) {
if (!flags.json) {
console.error(`${c.red}${c.bold}❌ CI Merge Gate Check Failed:${c.reset}`);
if (lowScore) console.error(` • Quality score (${result.scores.quality}) is below minimum threshold (${flags.minScore})`);
for (const g of failedGates) console.error(` • Gate failed: ${g.label} (${g.explanation})`);
}
process.exit(1);
}
}
That is the full exit-code contract:
| Exit code | Meaning |
|---|---|
0 | All 8 CLI-enforced gates pass and quality rubric ≥ --min-score (default 60) |
1 | At least one of the 8 CLI-enforced gates failed, or quality < --min-score, or the review threw (model unreachable, JSON parse failure, etc.) |
There is no exit 2 for "infra error vs gate error" — the CLI collapses them into 1. If you want to distinguish, parse stderr: gate failed: and Quality score <...> is below lines are gate/score failures; anything else (Error: Not a git repository, Cannot connect to model provider) is infra. Add --json to get a machine-readable shape on stdout and keep the gate-fail reasons on stderr.
The 8 CLI-enforced gates and what each one means in CI
The CLI's S1_GATES array (in bin/codeotter.mjs) declares 8 gates, each with a label, the rubric question, and a risk: true flag that colors the gauge bar:
| Gate id | Label | Risk-weighted | Rubric question (paraphrased) |
|---|---|---|---|
title | Title check | no | "The PR title accurately describes the change." |
description | Description check | no | "The PR description explains what changed and why." |
security | Security | yes | "The change introduces a security risk (injection, auth bypass, secrets, unsafe deserialisation, SSRF, XSS)." |
complexity | Complexity | yes | "The change is more complex than the problem requires." |
tests | Tests | no | "The change is adequately covered by tests for its risk." |
docs | Documentation | no | "Documentation is updated wherever behaviour visible to users or developers changed." |
scope | Scope | no | "The PR does one focused thing." |
guidelines | Repository guidelines | yes | "The change violates the repository's review guidelines." (AGENTS.md / CLAUDE.md, auto-loaded from the repo root by getLocalGuidelines.) |
A gate with risk: true is one we consider "could break prod if missed" — its gauge bar flips to red past 60 instead of yellow. The CLI does not filter which gates block a merge; any failed gate fails the run. If you want a stricter policy (e.g. security must fail but a complexity yellow is advisory), do it in your CI step, not in the CLI:
- name: CodeOtter merge gate run: node bin/codeotter.mjs ci --base origin/main --fail-on-gate --min-score 70
A job that exits 1 in a needs: chain short-circuits the dependent jobs. That is the contract.
The 9th gate lives server-side: Issue Requirements
The flagship dual-engine deep-dive and the AGENTS.md-as-merge-gate blog both cite 9 pre-merge gates, not 8. The 9th is Issue Requirements (gate_issue_requirements) — it parses the PR description for Closes #123, fetches the linked issue's acceptance criteria from the forge API, and validates the diff against them. The bad case is the obvious one: a PR closes issue #42 ("validate email at the boundary") but the diff only adds the validation in a unit test, not at the production boundary.
That gate is enforced by the platform's POST /v1/systemone rubric engine — see §3.1 of the dual-engine deep-dive. But it is not yet in S1_GATES inside bin/codeotter.mjs. The CLI works on a local diff with no forge API access by default, so it cannot resolve Closes #42 to a remote issue body. The platform has the OAuth token, the repo context, and the issue fetch.
The honest answer for a CI author right now: pair codeotter ci with the server-side codeotter/triage commit status to catch Issue Requirements. Triage runs POST /v1/systemone with full forge context, so it emits all 9 gates, and emits the codeotter/triage GitHub commit status that your branch protection can require to be success before merge. Triage is documented at docs/ci-triage.md on the CodeOtter repo.
A two-tier pipeline:
codeotter/triage(server-side, every 2 minutes) — fires on every push, gates the long build (cheap System 1 digest).codeotter ci(CLI, in the runner) — fires on PR build, gates the merge (full dual-engine againstorigin/main...HEAD).
The merge button requires both. The long build only requires codeotter/triage. That is the model.
JSON shape, exit codes, and CI consumption
codeotter ci --json prints the runReview payload to stdout and only the gate-fail reasons to stderr. The shape is the same JSON the platform sends to POST /v1/systemone (with a few local additions: blast.outsideCallers, outsideImpact.callers[] from the local git grep -n -w <symbol> trace). A truncated sample:
{
"title": "PR #142: scope validateEmail to the user-signup boundary",
"body": "Closes #42",
"files": [
{ "path": "src/auth/validate.ts", "additions": 12, "deletions": 4 },
{ "path": "src/auth/validate.test.ts", "additions": 28, "deletions": 0 }
],
"blast": {
"score": 42,
"files": 2,
"lines": 44,
"dirs": 1,
"hotspots": ["auth"],
"testFiles": 1,
"outsideCallers": 3,
"outsideFiles": 2
},
"outsideImpact": {
"outsideCallers": 3,
"uniqueFiles": 2,
"callers": [
{ "file": "src/auth/signup.ts", "line": 17, "symbol": "validateEmail", "code": "validateEmail(input)" },
{ "file": "src/auth/login.ts", "line": 41, "symbol": "validateEmail", "code": "validateEmail(input)" }
]
},
"scores": {
"quality": 78,
"correctness_risk": 18,
"test_coverage": 70,
"readability": 84,
"pr_hygiene": 80
},
"gates": [
{ "id": "title", "label": "Title check", "pass": true, "explanation": "..." },
{ "id": "description", "label": "Description check", "pass": true, "explanation": "..." },
{ "id": "security", "label": "Security", "pass": true, "explanation": "..." },
{ "id": "complexity", "label": "Complexity", "pass": true, "explanation": "..." },
{ "id": "tests", "label": "Tests", "pass": true, "explanation": "..." },
{ "id": "docs", "label": "Documentation", "pass": false, "explanation": "PUBLIC API CHANGE — no CHANGELOG entry for validateEmail boundary tightening." },
{ "id": "scope", "label": "Scope", "pass": true, "explanation": "..." },
{ "id": "guidelines", "label": "Repository guidelines","pass": true, "explanation": "..." }
],
"summary": "Tightens user-signup email validation to require a TLD. Validates both at the API boundary and at the persistence layer; existing tests cover the boundary case but the CHANGELOG was missed.",
"walkthrough": [
{ "file": "src/auth/validate.ts", "change": "Adds a TLD allowlist check in validateEmail; returns ValidationError instead of throwing." },
{ "file": "src/auth/validate.test.ts", "change": "Adds 6 boundary tests for TLD allowlist (whitelist, deny-list, IDN homograph, etc.)." }
],
"findings": [
{
"file": "src/auth/validate.ts",
"line": 42,
"severity": "suggestion",
"title": "Tighten IDN homograph check to Punycode-decode first",
"detail": "The current IDN guard checks raw Unicode. Punycode-decode first to catch Cyrillic-a / Latin-a substitutions.",
"suggestion": "if (!email.match(/^[\\x21-\\x7e]+@([\\w-]+\\.)+[a-z]{2,}$/i)) throw new ValidationError('email.invalid');"
}
],
"model": "anthropic/claude-3-5-sonnet-latest"
}
A typical GitHub Actions step that consumes this:
- name: CodeOtter merge gate
id: codeotter
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set +e
node bin/codeotter.mjs ci \
--base origin/main \
--fail-on-gate \
--min-score 70 \
--json > codeotter.json
rc=$?
set -e
echo "exit_code=$rc" >> "$GITHUB_OUTPUT"
echo "gate_fail_count=$(jq '[.gates[] | select(.pass == false)] | length' codeotter.json)" >> "$GITHUB_OUTPUT"
- name: Annotate PR with failed gates
if: steps.codeotter.outputs.exit_code != '0'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const review = JSON.parse(fs.readFileSync('codeotter.json', 'utf8'));
const failed = review.gates.filter(g => !g.pass);
const body = '🦦 CodeOtter CI gate failed:\n' +
failed.map(g => `- **${g.label}**: ${g.explanation}`).join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
That step exits non-zero on a failed gate, posts a PR comment naming them, and short-circuits everything in needs: codeotter.
Forgejo and Gitea runners: the review path, not the pr path
The CLI's pr subcommand shells out to gh pr view / gh pr diff / gh pr comment. gh is the GitHub CLI — it does not speak Forgejo or Gitea. For self-hosted runners, use the review subcommand against a checkout instead:
# Forgejo Actions / Gitea Actions example
- name: CodeOtter review on self-hosted runner
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
node bin/codeotter.mjs review \
--base ${{ github.event.pull_request.base.ref }} \
--fail-on-gate \
--min-score 70
The review subcommand is forge-agnostic: it consumes whatever diff is in the runner's working tree against --base. The trade-off is that --post-comment (which calls gh pr comment) only works on GitHub. For Forgejo / Gitea, the platform-side review (the one that runs on every webhook) does the PR comment posting — your CI step is just the gate.
For teams using the Gitea provider added on 2026-09-30, the same pattern works on .gitea/workflows/*.yml runners. CodeOtter's Gitea OAuth uses PKCE and requests read:user and user:email; verified primary email only is used for account linking.
A two-tier pipeline: codeotter/triage (server-side) + codeotter ci (CLI)
The pipeline a 50-engineer platform team wants looks like this:
View Mermaid source
flowchart LR
Push["git push<br/>headRefOid advances"] --> Hook["Forge webhook<br/>→ CodeOtter /review"]
Hook --> Poll1["Poll every 2 min<br/>→ System 1 digest<br/>→ codeotter/triage status"]
Poll1 -->|pending| Wait1["GitHub Actions<br/>codeotter-triage job<br/>polls statuses every 10s"]
Poll1 -->|success| LongBuild["Long build<br/>(unit + integration + e2e)"]
Poll1 -->|failure or error| Block1["Triage fail<br/>→ short-circuit long build"]
LongBuild --> CLI["codeotter ci<br/>--base origin/main<br/>--fail-on-gate"]
CLI -->|exit 0| PR["PR mergeable<br/>(requires codeotter/triage=success<br/>+ codeotter ci exit 0)"]
CLI -->|exit 1| Block2["CI gate fail<br/>→ author fixes → push again"]
Block2 --> Push
Two specifics worth remembering:
codeotter/triageis the cheap filter that protects the long build. Triage reads a digest of the whole change sized to the model's context — every changed file with its line counts, tests and areas, then the largest hunks — and rates correctness risk + blast radius. The default is ~80 seconds on CPU with Kev 0.8B. Thecodeotter/triagecommit status is polled every 2 minutes on the platform, so no inbound webhook is needed. A red triage status short-circuits the expensive jobs.codeotter ciis the strict filter that protects the merge button. It runs the full dual-engine againstorigin/main...HEADwith the sameS1_SCORESandS1_GATESas the platform. The merge button requires both:codeotter/triage=successin branch protection and the CI step exits 0. Together they enforce all 9 gates (8 CLI + the server-sideIssue Requirementsgate).
The trade-off is honest: triage is ~80 seconds on CPU, CLI review is ~5 minutes wall-clock on first run with a cold GGUF (subsequent runs are faster because the runtime is warm). Both are local. Neither sends your diff to a third party.
What this changes for a team
For the platform engineer wiring this in, the contract is two CLI flags and a JSON file. node bin/codeotter.mjs ci --base origin/main --fail-on-gate --min-score 70 --json > codeotter.json is the entire integration. There is no SaaS webhook, no hosted model, no per-seat tax.
For the security lead writing the audit story, the gate definitions are not magic embeddings. S1_GATES is a typed 8-element array in bin/codeotter.mjs; S1_SCORES is a typed 6-key object with named anchors at every 25-point index. Every gate failure comes with the rubric question text. The audit reads it back through your CI logs.
For the AI coding agent writing the code, bin/codeotter.mjs ci --base origin/main --fail-on-gate --min-score 70 is the merge-gate contract. The agent runs the gate on its own diff before git commit and gets a typed scorecard + committable suggestion blocks back. The anti-hallucination critique pass is on by default.
For the CFO comparing this to CodeRabbit / Greptile, the dollar math is the same as P2: $0 forever on self-hosted infra vs $24–$30/dev/month on SaaS. The CI integration does not change that — there is no per-CI-run tax.
Where this still loses, honestly
- The 9th gate is server-side only.
Issue Requirements(gate_issue_requirements) is enforced byPOST /v1/systemonewith full forge context (the CLI does not fetchCloses #42from the issue tracker). Paircodeotter ciwith the server-sidecodeotter/triagecommit status to catch it. We have not yet backported the gate intoS1_GATES; tracked inbin/codeotter.mjsS1_GATES. - The CLI's
prsubcommand is GitHub-only. It shells out togh. For Forgejo / Gitea, usereview --base <branch>on the runner instead. The platform-side review (with the Forgejo OAuth shipped 2026-09-30) does the PR comment posting for self-hosted forges. - 5-minute cold-start on first review. The first time the CLI hits a local System 1 model (Kev 0.8B or Laya Typed-Decisions), the runtime loads the GGUF and warms up. Subsequent runs are faster. If your CI step budget is sub-2-minute, the cold start is the bottleneck. Warm caches via
pnpm starton a long-lived runner, or use a hosted API key for the first review and a local model after. - BYOK keys still need to be in CI secrets. Use OIDC for GitHub Actions (no long-lived secret), sealed secrets for K8s, or
process.loadEnvFilefrom a vault-mounted.envfile. The CLI does not store keys; it reads them at runtime. - No
--post-commentincimode.--post-commentworks on theprsubcommand (GitHub only) — it shells out togh pr comment. Thecisubcommand is gate-only and does not annotate the PR. If you want a PR comment on failure, do it in CI as in §4 above. - AGPL-3.0 is OSI-approved copyleft. License is GNU Affero General Public License v3.0 (AGPL-3.0), free to self-host, modify, and redistribute; a modified version offered as a network service must publish its source. Zero per-seat billing, zero telemetry. The CLI ships under the same license. You own the binary; if you offer it as a network service, you owe the changes back.
Where to go next
- Walk the dual-engine architecture in the flagship deep-dive — the System 1 + System 2 rubric contract and the 9-gate verdict are documented in §3.1.
- See how the 9th gate (
Issue Requirements) is enforced server-side in the AGENTS.md-as-merge-gate blog — same engine, server-sidePOST /v1/systemonewith full forge context. - The
prevSha → headShaincremental delta review path is documented in the incremental-delta engineering blog — the CLI reuses the sameincremental_review_deltastate field onPOST /v1/systemone. - The
codeotter/triageserver-side commit status + GitHub Actions wait job is documented atdocs/ci-triage.mdon the CodeOtter repo. - The CLI source —
bin/codeotter.mjs, CLIv0.4.0, zero runtime dependencies — is atdharmeshgurnani/CodeOtter/bin/codeotter.mjs.
If you want a CodeOtter CI hook on your own repo, vendor bin/codeotter.mjs into your repo, add a codeotter ci --base origin/main --fail-on-gate step, and pair it with the server-side codeotter/triage branch protection requirement. The merge button stops on a red gate.
CodeOtter ships under the GNU Affero General Public License v3.0 (AGPL-3.0) — free to self-host, modify, and redistribute; a modified version offered as a network service must publish its source. Zero per-seat billing, zero telemetry. CLI: node bin/codeotter.mjs. Subcommand: ci. Flags: --fail-on-gate, --min-score 60, --json. Exit 0 is clean. Exit 1 is a gate or score fail, or infra. The whole merge does not send your diff to a third party.


