kairoku.json
How a repository tells a daemon what a run of it needs.
kairoku.json at the root of a repository is how that repository says what one run of it needs: what to install, what services to stand up, what to check, and how to run its tests. A repository without one still runs — the daemon falls back to copying the checkout's .env* files and to package.json's own lint, build and test scripts.
It is read from the committed default branch, never from the worktree — git show origin/<default-branch>:kairoku.json. The worktree is where an implementer is editing, and a manifest an agent rewrote thirty seconds ago is not the repository's contract; the commands in it are run by the daemon with the run's own environment.
The schema is non-strict: unknown keys are ignored rather than refused, so a "$comment" explaining the file is not a build failure. What is strict is the type of every key that is read, and an error names the JSON path — env.test.ports[1], not "invalid manifest" — because the person reading it is an operator looking at a run that failed on a machine they cannot see.
The keys
| Key | Type | What it does |
|---|---|---|
setup | array of strings | Run once in a fresh worktree before anything else — bun install, go mod download |
env.<profile>.files | array of strings | Dotenv files from the checkout that seed the lowest layer of the value merge |
env.<profile>.compose | string | A compose file, relative to the repository root. Absent means no services. It must stay inside the repository — no absolute path, no .. |
env.<profile>.ports | array of strings | Names of ports to allocate per run; substituted into inject as ${NAME} |
env.<profile>.inject | object | Values placed at the top of the merge, after ${PORT} substitution |
env.<profile>.init | array of strings | Run in the worktree with the merged environment once the services are healthy — migrations, seeds |
check | array of strings | The QA step's gate commands, in order |
test | string | The QA step's test command. Its summary is parsed for the four counts |
intelligence | array of strings | Code intelligence this repository opts into. ["codegraph"] is the only accepted value today; see below |
concurrency.test | number | How many runs of this repository may be in their test step at once on one machine |
Two things a run is held to are deliberately not keys here. The QA step scans the repository's own .kairoku/rules/ whenever the base branch has that directory, and every role reads .kairoku/patterns.md before its first write. Neither is asked for in this file, because a repository should not be able to opt its own gate out in the same file the gate reads.
Three examples
A Next.js app on bun, with Postgres
{
"setup": ["bun install"],
"env": {
"test": {
"files": [".env.local"],
"compose": "compose.test.yml",
"ports": ["PG_PORT", "PROXY_PORT"],
"inject": {
"DATABASE_URL": "postgres://postgres:postgres@127.0.0.1:${PG_PORT}/main",
"NEON_PROXY_URL": "http://127.0.0.1:${PROXY_PORT}/sql"
},
"init": ["bun run db:migrate"]
}
},
"intelligence": ["codegraph"],
"check": ["bunx next typegen", "bunx tsc --noEmit", "bun run lint", "bun run build"],
"test": "bun test --timeout 30000 --parallel",
"concurrency": { "test": 2 }
}Kairoku's own web app manifest is this one without a database: its test profile carries only "files": [".env.local"] — no compose, ports, inject or init — and it adds a "$comment" and an "$intelligence" note, which the reader ignores like any other unknown key.
A Go service with Redis
{
"setup": ["go mod download"],
"env": {
"test": {
"files": [".env.test"],
"compose": "docker-compose.test.yml",
"ports": ["REDIS_PORT"],
"inject": { "REDIS_URL": "redis://127.0.0.1:${REDIS_PORT}" },
"init": []
}
},
"check": ["go vet ./...", "go build ./..."],
"test": "go test ./...",
"concurrency": { "test": 1 }
}concurrency.test is 1 here on purpose: a suite that binds a fixed port or writes a fixed path is not safe beside a second copy of itself, and saying so in the manifest is cheaper than finding out from a flaky run.
A static site with no services
{
"setup": ["npm ci"],
"env": { "test": { "files": [], "ports": [], "inject": {}, "init": [] } },
"check": ["npm run lint", "npm run build"],
"test": "npm test",
"concurrency": { "test": 4 }
}No compose key, so nothing is stood up and nothing is torn down. This is the common case.
Code intelligence, on probation
intelligence opts a repository into a code index for its runs. It is read from the base branch like every other key, and absent or empty changes nothing — which is the default and, for now, the recommendation.
"intelligence": ["codegraph"]"codegraph" is the only value that exists. Anything else is refused with its JSON path — intelligence[1] is not one of: codegraph — rather than ignored, which is the one place this reader is strict about a value and not just a type. A repository that typed codegrpah and was silently given nothing would look exactly like a repository the index did not help.
What a run gets when it is on
After the worktree is cut and before the first role turn, the daemon looks for codegraph on PATH and runs codegraph init over that worktree. .codegraph/ is excluded from the run's git first, so even a failed index leaves nothing committable.
The index is timed and recorded as one line in the run's events — codegraph: 1483 files in 41.2s, or codegraph: not indexed — <reason>. The file count comes from codegraph status --json, not from a regex over the indexer's progress output.
Every role turn is handed a second, read-only MCP server pinned to that worktree — Claude through the SDK's server option, Codex through a [mcp_servers.codegraph] table in the worktree's own per-run config — and one sentence is added to the role prompt, only on a run that actually has an index: "codegraph_explore is available for orientation and blast radius; read the file before you edit it."
One index per worktree, and that is not a choice. CodeGraph refuses to share an index across git worktrees — a single index cannot correctly represent two branches at once — so three concurrent members mean three cold indexes on that machine. That cost is exactly what the probation is measuring.
The probation, stated plainly
CodeGraph is on probation and may be removed. It stays only if a measurement says it earns its cost: the same three plan items, run twice each way, and it keeps its place only with fewer tool calls or less wall clock per item at equal-or-better QA counts. If it loses, the key comes out of the manifests — and the feature comes out of the daemon. It is not replaced with something else.
It degrades silently, and that is the difference between the two halves of this feature. No codegraph on the machine, an index that fails, a lock held by something else: the run proceeds without it and never fails for its absence. kairoku doctor is where an operator learns a machine has none, as a WARN.
The repository's own rules do the opposite and fail a run closed, because a rule nobody checked is a false clean report while an index nobody built is only a slower agent.
Where a value comes from
Four layers, merged low to high — a later layer replaces an earlier one of the same name.
The checkout's own .env* files, as named by env.<profile>.files.
The machine's env store — ~/.kairoku/env/<owner>/<repo>/<profile>.env, mode 600. Values that belong to this machine and this repository, not to the project.
The app's secrets, delivered in the claim for the run's profile. Held in memory only, never written under ~/.kairoku, and masked out of every event and log.
Per-run values — the ports allocated for this run, then the manifest's inject with ${PORT} substituted, then KAIROKU_RUN_ID and KAIROKU_DISPATCH_ID, and KAIROKU_PAT last, so a manifest naming KAIROKU_PAT in its own inject cannot hand the agent a credential of the repository's choosing.
The env store
kairoku env set KEY=value [KEY=value ...] # add or replace values
kairoku env import <file> # merge a dotenv file in
kairoku env list # the NAMES held here, never the values
kairoku env rm KEY [KEY ...] # remove valuesAll four take --repo <owner/name> (default: the origin of the checkout named in config.json) and --profile <name> (default: test). A checkout whose origin cannot be read is refused rather than guessed at — writing to the wrong repository's store would hand one project's values to another project's agents.
list prints names and never values, which is the same discipline the app's secrets table keeps. If you need to know a value, you already have it somewhere it can be read from.
Services and ports
Every run that names a compose file gets its own compose project — docker compose -p kairoku-<runId> -f <compose> up --wait — with the ports named in env.<profile>.ports allocated free from the daemon's range and exported into it. Two runs of the same repository on one machine therefore each get their own database on their own port, and neither can see the other's.
Everything binds 127.0.0.1. A per-run service published on a LAN address would be reachable by anything on the network, which is the wrong bargain for a database holding a run's test fixtures.
Teardown runs docker compose down -v --remove-orphans on every exit path — done, failed, cancelled, timed out, and daemon shutdown — and kairoku daemon prune picks up orphaned kairoku-* projects left by a daemon that died.
Docker requirements
- Docker with the compose plugin —
docker compose versionmust answer.kairoku setup --daemoninstalls it with apt on Linux and names the installer on macOS (OrbStack or Docker Desktop) rather than installing one for you. - A port range,
portsin~/.kairoku/config.json,"20000-29999"by default. Setup asks for it;kairoku doctorchecks that the range still has free ports in it. - The daemon user must be able to talk to the Docker socket without a password prompt.
A repository with no compose key needs none of this, and doctor reports Docker's absence rather than failing a machine that will never need it.