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 codeMeaning
0All 8 CLI-enforced gates pass and quality rubric ≥ --min-score (default 60)
1At 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 idLabelRisk-weightedRubric question (paraphrased)
titleTitle checkno"The PR title accurately describes the change."
descriptionDescription checkno"The PR description explains what changed and why."
securitySecurityyes"The change introduces a security risk (injection, auth bypass, secrets, unsafe deserialisation, SSRF, XSS)."
complexityComplexityyes"The change is more complex than the problem requires."
testsTestsno"The change is adequately covered by tests for its risk."
docsDocumentationno"Documentation is updated wherever behaviour visible to users or developers changed."
scopeScopeno"The PR does one focused thing."
guidelinesRepository guidelinesyes"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:

  1. codeotter/triage (server-side, every 2 minutes) — fires on every push, gates the long build (cheap System 1 digest).
  2. codeotter ci (CLI, in the runner) — fires on PR build, gates the merge (full dual-engine against origin/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:

Two specifics worth remembering:

  1. codeotter/triage is 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. The codeotter/triage commit status is polled every 2 minutes on the platform, so no inbound webhook is needed. A red triage status short-circuits the expensive jobs.
  2. codeotter ci is the strict filter that protects the merge button. It runs the full dual-engine against origin/main...HEAD with the same S1_SCORES and S1_GATES as the platform. The merge button requires both: codeotter/triage=success in branch protection and the CI step exits 0. Together they enforce all 9 gates (8 CLI + the server-side Issue Requirements gate).

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 by POST /v1/systemone with full forge context (the CLI does not fetch Closes #42 from the issue tracker). Pair codeotter ci with the server-side codeotter/triage commit status to catch it. We have not yet backported the gate into S1_GATES; tracked in bin/codeotter.mjs S1_GATES.
  • The CLI's pr subcommand is GitHub-only. It shells out to gh. For Forgejo / Gitea, use review --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 start on 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.loadEnvFile from a vault-mounted .env file. The CLI does not store keys; it reads them at runtime.
  • No --post-comment in ci mode. --post-comment works on the pr subcommand (GitHub only) — it shells out to gh pr comment. The ci subcommand 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-side POST /v1/systemone with full forge context.
  • The prevSha → headSha incremental delta review path is documented in the incremental-delta engineering blog — the CLI reuses the same incremental_review_delta state field on POST /v1/systemone.
  • The codeotter/triage server-side commit status + GitHub Actions wait job is documented at docs/ci-triage.md on the CodeOtter repo.
  • The CLI source — bin/codeotter.mjs, CLI v0.4.0, zero runtime dependencies — is at dharmeshgurnani/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.