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
basestage. - Bun version. Pinned in
.tool-versions(bun 1.4.4). All workflows useoven-sh/setup-bunwithbun-version-file: .tool-versions..gitlab-ci.yml'soven/bun:<ver>pins are also verified bycheck: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 isbundler, imports do not need.jsextensions.
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 throughoxlint-tsgolint, which uses TypeScript 7 (options.typeAware: true). It picks the nearesttsconfig.jsonper file, and the root one excludestest/, sotest/tsconfig.jsonextendstsconfig.test.jsonto give tests the same strict flags. Without it,noUncheckedIndexedAccessis off for tests andno-unnecessary-type-assertionstrips the!assertions they need. - JS plugins.
eslint-plugin-security,eslint-plugin-simple-import-sort, andoxlint-plugin-eslint(prefixeslint-js/) for ESLint core rules Oxlint does not implement:require-atomic-updatesandno-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: errortypescript/no-explicit-any: warnno-unused-vars(underscore-prefix exempted)simple-import-sort/imports,simple-import-sort/exports: auto-fixablecomplexity: 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-chainis off: the Oxlint version suggests rewrites that are not equivalent, e.g.row === undefined || row.x === nulltorow?.x === null.no-unsafe-optional-chainingis off in tests, where(x?.[0] as T).propis a deliberate assertion.typescript/no-unsafe-enum-assignmenthas no Oxlint equivalent. The codebase declares no enums.- Special restriction.
src/workflows/ship/scoped/triage.tscarries aneslint-js/no-restricted-syntaxrule 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:
gitleaks protect --staged, secret scan. Hard exit 1 ifgitleaksis missing.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
pinowith child loggers per request. - The ship workflow draws every
eventvalue from the typedSHIP_LOG_EVENTSconstant insrc/workflows/ship/log-fields.tsso a typo is a compile error. See../operate/observability.md.
Configuration¶
- All process-level configuration is validated via
zodat startup insrc/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 audit:ci (used by CI) wraps bun audit --json to gate on severity:
- Blocks on
highandcriticaladvisories. - Warns on
moderateandlow. - Inline GHSA allowlist in
IGNOREDarray; each entry hasghsa,reason, andexpires(ISO date). Expired entries become warnings on next run. - Retries up to 3 times when
bun auditproduces 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, andtrivy-scan.ymlscans 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'surl) and hard-fails on an unrecognised shape so a format change cannot silently degrade the gate tototal=0. A severity value outside the known set is not shape drift: it blocks with anUNKNOWN SEVERITYannotation 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_JSONoverrides the inlineIGNOREDlist 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/coreescapeDatarule (%/ 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 onubuntu-24.04-arm(free for public repos). Both runners are explicitly pinned (notubuntu-latest) so the rolling alias cannot silently flip to a new major. Manifest assembled bydocker 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*-latestrolling alias.ci.ymlrunsbun run check:runner-pins(scripts/check-runner-pins.ts), which fails the build if anyruns-on:is on a*-latestalias; 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.ymland the GitLabguardsjob runbun run check:claude-code-version(scripts/check-claude-code-version.ts), which fails unless theCLAUDE_CODE_VERSIONARG in bothDockerfile.orchestratorandDockerfile.daemonequals the installed SDK'sclaudeCodeVersion.renovate.jsongroups@anthropic-ai/claude-agent-sdkand@anthropic-ai/claude-codeinto 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 viaenv:first. - CVE scanning is decoupled from releases.
trivy-scan.ymlis a separate workflow, not a job insidedocker-build.yml. A scan gated behindneeds: mergeruns 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 skipnotify-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
mainopens/updates a stable Release PR; merging it tagsv<x.y.z>, updatesCHANGELOG.md+package.json, creates the GitHub release, and builds the prod image (latest). Thebetabranch does the same for prereleases (v<x.y.z>-beta, nolatest). Onlyfeat/fix/!commits bump the version. Branch selection uses two source-controlled config + manifest pairs (release-please-config.json/.release-please-manifest.jsonon main, the.betavariants on beta). On a stablemainrelease, thenotify-helm-chartsjob (afterdocker) sends arepository_dispatchtochrisleekr/helm-chartsto 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.