Skip to content

Code conventions

The repo enforces conventions through tooling, not docs, every rule below is checked by bun run check. This page is a tour of what's wired so you know what to expect.

Runtime and language

  • Runtime. Bun for the application; Node.js 26 for the Claude Code CLI subprocess. Both are installed in the Docker base stage.
  • Bun version. Pinned in .tool-versions (bun 1.4.4). All workflows use oven-sh/setup-bun with bun-version-file: .tool-versions. .gitlab-ci.yml's oven/bun:<ver> pins are also verified by check:docs-versions, which additionally asserts the two digest-pinned references agree with each other and that the file yields at least one reference to check.
  • TypeScript. Strict mode plus the strictest flags: exactOptionalPropertyTypes, noUncheckedIndexedAccess, useUnknownInCatchVariables, noUnusedLocals, noUnusedParameters, noImplicitReturns, noFallthroughCasesInSwitch, noImplicitOverride, noPropertyAccessFromIndexSignature. Module resolution is bundler, imports do not need .js extensions.

Oxlint

.oxlintrc.json is the config. It was generated by @oxlint/migrate from the former ESLint flat config and keeps the same rule set:

  • Preset. ESLint recommended + typescript-eslint strictTypeChecked + stylisticTypeChecked, as explicit rules. Type-aware rules run through oxlint-tsgolint, which uses TypeScript 7 (options.typeAware: true). It picks the nearest tsconfig.json per file, and the root one excludes test/, so test/tsconfig.json extends tsconfig.test.json to give tests the same strict flags. Without it, noUncheckedIndexedAccess is off for tests and no-unnecessary-type-assertion strips the ! assertions they need.
  • JS plugins. eslint-plugin-security, eslint-plugin-simple-import-sort, and oxlint-plugin-eslint (prefix eslint-js/) for ESLint core rules Oxlint does not implement: require-atomic-updates and no-restricted-syntax. Disable comments must use the prefixed name, e.g. eslint-disable-next-line eslint-js/require-atomic-updates.
  • Notable rules.
  • typescript/strict-boolean-expressions: error
  • typescript/no-explicit-any: warn
  • no-unused-vars (underscore-prefix exempted)
  • simple-import-sort/imports, simple-import-sort/exports: auto-fixable
  • complexity: warn (15), max-lines-per-function: warn (120), max-nested-callbacks: error (3)
  • Security rules from eslint-plugin-security:recommended
  • Differences from the ESLint setup.
  • typescript/prefer-optional-chain is off: the Oxlint version suggests rewrites that are not equivalent, e.g. row === undefined || row.x === null to row?.x === null.
  • no-unsafe-optional-chaining is off in tests, where (x?.[0] as T).prop is a deliberate assertion.
  • typescript/no-unsafe-enum-assignment has no Oxlint equivalent. The codebase declares no enums.
  • Special restriction. src/workflows/ship/scoped/triage.ts carries an eslint-js/no-restricted-syntax rule forbidding GitHub mutations: ship-side triage is suggest-only.

Prettier

.prettierrc:

Rule Value
semi true
singleQuote false
trailingComma "all"
printWidth 100
tabWidth 2
endOfLine "lf"
arrowParens "always"
bracketSpacing true

Em dashes

The repo-wide style rule (per ~/.claude/CLAUDE.md) forbids em dashes (U+2014). Use commas, colons, semicolons, or parentheses instead. Enforced in CI via scripts/em-dash-sweep.ts --check (also wired into the umbrella bun run check). The check skips src/db/migrations/, CHANGELOG.md, test/**/fixtures/, and build outputs. To remediate an offending line locally, run bun run scripts/em-dash-sweep.ts <path> to apply the heuristic rewrite, then hand-review the result. See issue #116 for the original sweep.

Pre-commit hooks

.husky/pre-commit:

  1. gitleaks protect --staged, secret scan. Hard exit 1 if gitleaks is missing.
  2. bunx lint-staged, Prettier and Oxlint on staged files.

.husky/commit-msg runs commitlint against @commitlint/config-conventional. Allowed types: feat, fix, docs, style, refactor, test, chore, perf, build, ci, revert, localize, bump.

Logging

  • Structured JSON via pino with child loggers per request.
  • The ship workflow draws every event value from the typed SHIP_LOG_EVENTS constant in src/workflows/ship/log-fields.ts so a typo is a compile error. See ../operate/observability.md.

Configuration

  • All process-level configuration is validated via zod at startup in src/config.ts. The process exits with a clear error if any required variable is missing or malformed.
  • Environment variable group is the canonical doc surface: see ../operate/configuration.md.

Scripts

The full list lives in ../operate/setup.md. For PRs, what matters is bun run check:

bun run check
# typecheck + lint + format + check:no-destructive + check:docs-sync + tests

bun run audit:ci (used by CI) wraps bun audit --json to gate on severity:

  • Blocks on high and critical advisories.
  • Warns on moderate and low.
  • Inline GHSA allowlist in IGNORED array; each entry has ghsa, reason, and expires (ISO date). Expired entries become warnings on next run.
  • Retries up to 3 times when bun audit produces no JSON, then emits a ::warning:: and passes. An unreachable advisory service yields no verdict either way, so it degrades to a loud skip rather than blocking every merge for the length of an upstream outage. Unparseable JSON still hard-fails, and trivy-scan.yml scans the published images daily as the independent control.
  • Parses bun audit --json's real shape (a map of package name to advisory list, GHSA id taken from each advisory's url) and hard-fails on an unrecognised shape so a format change cannot silently degrade the gate to total=0. A severity value outside the known set is not shape drift: it blocks with an UNKNOWN SEVERITY annotation so the rest of the report still reports. GHSA extraction tolerates a query string, fragment or trailing slash and matches case-insensitively, so a cosmetic url change cannot silently void an allowlist entry. AUDIT_CI_ALLOWLIST_JSON overrides the inline IGNORED list for tests only.
  • Never lets registry-supplied text start a GitHub Actions workflow command: advisory fields embedded in ::error:: / ::warning:: / ::notice:: lines are escaped per the @actions/core escapeData rule (% / CR / LF to %25 / %0D / %0A), and raw stdout or stderr dumps are printed one line at a time behind a > prefix so no dumped line can begin with ::.

CI pipeline

Six pipeline files; each owns one responsibility.

Workflow Trigger Owns
.github/workflows/ci.yml pull_request + push: main + workflow_call Quality gates only: typecheck, lint, format, audit:ci, test, build
.github/workflows/secrets-scan.yml push: branches-ignore: [gh-pages] + workflow_dispatch Standalone gitleaks scan, decoupled so every push (incl. chore/docs) is gated
.github/workflows/release-please.yml push: [main, beta] release-please maintains a Release PR per branch; merging it cuts the release then calls docker-build.yml, and on a stable main release dispatches github-app-released to chrisleekr/helm-charts to open the chart-sync PR
.github/workflows/docker-build.yml workflow_call + workflow_dispatch Reusable image builder: matrix split-and-merge (amd64 on ubuntu-24.04, arm64 on ubuntu-24.04-arm). Tags each variant <version>-<variant> plus a mutable latest-<variant> on prod releases. No CVE scanning
.github/workflows/trivy-scan.yml schedule (daily 14:00 UTC) + workflow_dispatch Trivy CVE scan of the published Docker Hub images, SARIF to the GitHub Security tab. Orchestrator legs gate on CRITICAL/HIGH; daemon legs report only, unfiltered by .trivyignore.yaml
.gitlab-ci.yml every branch (gates) + main (images) GitLab CI: the same quality gates on every branch, then latest-orchestrator / latest-daemon to the GitLab container registry on main

Notes:

  • Multi-arch images. amd64 builds on ubuntu-24.04, arm64 builds natively on ubuntu-24.04-arm (free for public repos). Both runners are explicitly pinned (not ubuntu-latest) so the rolling alias cannot silently flip to a new major. Manifest assembled by docker buildx imagetools create. GHA cache scoped per arch.
  • Runners pinned repo-wide. Every runs-on: across .github/workflows/ targets an explicit image (ubuntu-24.04), never a *-latest rolling alias. ci.yml runs bun run check:runner-pins (scripts/check-runner-pins.ts), which fails the build if any runs-on: is on a *-latest alias; matrix expressions are exempt. See issue #173.
  • Claude Code CLI pin guard. Both images set CLAUDE_CODE_PATH=/usr/bin/claude, so the Agent SDK drives the Dockerfile-installed CLI, not its bundled one. ci.yml and the GitLab guards job run bun run check:claude-code-version (scripts/check-claude-code-version.ts), which fails unless the CLAUDE_CODE_VERSION ARG in both Dockerfile.orchestrator and Dockerfile.daemon equals the installed SDK's claudeCodeVersion. renovate.json groups @anthropic-ai/claude-agent-sdk and @anthropic-ai/claude-code into one PR, but Renovate picks each version independently, so the guard, not the grouping, is what enforces the match. An SDK bump must move the ARG in the same change.
  • Defense in depth. Every dynamic input flowing into a run: block is passed via env: first.
  • CVE scanning is decoupled from releases. trivy-scan.yml is a separate workflow, not a job inside docker-build.yml. A scan gated behind needs: merge runs after the manifest lists are already pushed, so it can never stop a vulnerable image from shipping; all it did was fold a CVE verdict into the reusable workflow's conclusion and skip notify-helm-charts, withholding the chart-sync PR for an image that was already live (this happened on v1.17.0). The daily schedule also catches advisories published after a release, which is how that verdict actually moves.
  • Releases run on release-please. Push to main opens/updates a stable Release PR; merging it tags v<x.y.z>, updates CHANGELOG.md + package.json, creates the GitHub release, and builds the prod image (latest). The beta branch does the same for prereleases (v<x.y.z>-beta, no latest). Only feat/fix/! commits bump the version. Branch selection uses two source-controlled config + manifest pairs (release-please-config.json / .release-please-manifest.json on main, the .beta variants on beta). On a stable main release, the notify-helm-charts job (after docker) sends a repository_dispatch to chrisleekr/helm-charts to open the chart-sync PR.

Documentation discipline

When a PR touches any of these surfaces, update the matching page under docs/:

Source Doc
src/config.ts env schema docs/operate/configuration.md
src/shared/dispatch-types.ts docs/operate/observability.md + docs/build/architecture.md
src/orchestrator/triage.ts docs/operate/runbooks/triage.md
src/webhook/ routing or idempotency docs/build/architecture.md
src/k8s/ephemeral-daemon-spawner.ts docs/operate/runbooks/daemon-fleet.md + docs/operate/deployment.md
src/daemon/ lifecycle docs/operate/runbooks/daemon-fleet.md
src/workflows/ registry, dispatcher, handlers docs/use/workflows/*.md
src/scheduler/ docs/use/scheduled-actions.md + docs/operate/runbooks/scheduled-actions.md
src/repo-config/ or .github-app.yaml schema docs/use/repo-config.md + the per-repo config sections of docs/build/architecture.md, docs/operate/observability.md, docs/operate/configuration.md
src/orchestrator/review-learnings.ts docs/use/review-learnings.md + docs/operate/configuration.md
New MCP server in src/mcp/ docs/build/extending.md
New Pino field or metric docs/operate/observability.md

bun run docs:build (strict) runs in CI. check:docs-sync blocks PRs that touch src/workflows/** without an accompanying docs change.