Docs

Features

Configuration

One file, vinaya.config.json, declares the gates, the checks, CI and where the Log goes. An empty file is a full harness; add a key only to change what runs.

vinaya.config.json
All optional
Harness keys
Trust anchor

About configuration

Everything a team adapts lives in one file at the repository root: vinaya.config.json. It is plain JSON, reviewed and committed like any other file.

Every key has a default. An empty file runs the full harness, so you add a key only when you want to change what runs.

Each key belongs to one of two owners:

  • The harness: the generic machinery every process uses, such as where the Log goes and which agent runs. Those keys are on this page.
  • The process: what a process declares, such as its roles, brief shape and review policy. For Vinaya, see Vinaya keys.

How it works

  1. 1

    Install seeds a starter

    vinaya init writes a starter file and records what it installed in managed. See Setup commands.

  2. 2

    You add only what you change

    Leave a key out and its default applies. An unknown top-level key is dropped.

  3. 3

    Sensitive keys come from the default branch

    Keys that decide who may merge, or name a destination or a program an unattended run would follow, are read from the default branch, not from the pull request. A change cannot rewrite its own rules. See Trust.

  4. 4

    A machine-wide file adds defaults

    ~/.vinaya/config.json applies to every repository on the machine. Keys that decide what runs or who is trusted are ignored there and honoured only from the committed repository file.

The harness keys

logs

false | object (optional)

false disables every Log destination for both runners: no folder files, no server retry outbox and no server requests, including synchronous process-exit recording.

Where the Vinaya Log delivers events LIVE, as they occur — no end-of-round batch, no tracker or code-host comment, and no logPublish key any more (removed: a config still carrying it is refused, naming this key). A folder (logs.folder) or a server (logs.url), never both. Absent — this key's own default — events go to a folder under this repository's own runtimeDir: <runtimeDir>/logs/<owner>-<repo>/<task>.ndjson, one line appended per event, in order, readable while the run is still going.

A server destination (logs.url) delivers each event as it occurs too: the sink appends it to a small local retry queue first, then drains that queue in one POST — the queue holds events only while the server is unreachable, and a later event's own drain catches up whatever is still queued, in order, once it is back. logs.headers are extra HTTP headers merged into that POST; a value may reference an environment variable with ${VAR_NAME} instead of a literal secret, resolved at delivery time so a credential never sits in the committed config.

Read from the default branch only by an unattended caller, the same rule runtimeDir carries: a logs.folder/logs.url the working tree declares but the default branch does not is refused, and the per-repository default folder is used instead — a pull request under review cannot redirect an unattended run's own telemetry by editing its own diff. An interactively-run command honours the working tree directly. logs.folder must also be an absolute path, for the same reason runtimeDir requires one.

A CI job never falls back to a folder, credentialed or not: it delivers to the configured logs.url when this job's own environment resolves every referenced header credential to a real value, and otherwise records nothing for this run and says so in the job's own output — never a tracker, a code-host comment, or a CI artifact. A job holding no credential (a fork pull request; GitHub withholds repository secrets from one) is exactly this second case, not a failure.

logs.events declares the repository's own log events by name, each <namespace>.<event> (lowercase, at most 64 characters; the vinaya. namespace is reserved) with at most 20 flat fields, each typed "text", "number", "boolean", or a list of words the value must be one of. Every declared field is required; nested values and lists are never a value, and a text value is at most 500 characters. A declaration that breaks a rule is refused at load, naming the entry. A declared event is recorded as one custom line; an undeclared or malformed one writes none and records one operation refusal (log.emit) naming the reason and the field names, never the values. Not a destination, so not read from the default branch only.

{
  "logs": {
    "url": "https://ingest.example.com/vinaya",
    "headers": { "authorization": "Bearer ${VINAYA_LOG_TOKEN}" },
    "events": {
      "acme.deploy": { "fields": { "env": ["prod", "staging"], "count": "number", "ok": "boolean" } }
    }
  }
}

runtimeDir

string (optional)

The one directory every file a task's run writes lives under, laid out as <runtimeDir>/tasks-execution/<task>/ with the task's files classified by nature: control/ for the control-store records, sessions/ for vendor session ids, hooks/ for the per-run documentation-check files, output/ for raw agent output and the driver's own log, rounds/<n>/ for that round's reviewer hand-off files and its read-only candidate, and the driver lock at the task folder's root. Absent — this key's own default — runs write under ~/.vinaya/runtime/<owner>-<repo> instead; the repository segment is kept there because task numbers repeat across repositories.

Set it to put the tree on a disk with room, or somewhere an existing backup and retention policy already covers. The logs setting's own default folder (below) sits under this directory, so moving runtimeDir moves the default telemetry destination along with it — a configured logs.folder/logs.url is unaffected either way.

Repo-local only, and absolute. It is stripped from the machine-global ~/.vinaya/config.json with a warning, the same as checks/roles/principals/releaseActor/tokens: only the DEFAULT carries the <owner>-<repo> segment, so a machine-wide value would collapse every repository into one tree and give two repositories' identically-numbered tasks the same driver lock, ownership epochs and session records. A relative path is refused (each process would resolve it against its own working directory), and so is one naming a directory inside the repository (a confined role can write there).

Read from the default branch only by an unattended caller, the same rule logs carries: a value the working tree declares but the default branch does not is refused and the per-repository default is used instead, so a pull request under review cannot redirect the driver's own lock, control records and reviewer hand-off files into a tree its own agent is allowed to write. An interactively-run command honours the working tree directly.

Changing it does not move what earlier runs left behind — nothing reads or migrates the old folders, so let a run in flight finish, or cancel it, before changing this key.

{ "runtimeDir": "/var/lib/vinaya/runs" }

dispatch

object (optional)

vinaya dispatch <role> --agent claude|codex (apps/cli/src/lib/dispatch.ts) settings: role-scoped wall-time ceilings before the headless child is signaled, and a default vendor for repos that always dispatch the same one.

{
  "dispatch": {
    "timeoutMs": 3600000,
    "reviewerTimeoutMs": 1200000,
    "agent": "claude"
  }
}

dispatch.timeoutMs

number (optional, positive integer)

The wall-time ceiling for a Developer dispatch. Absent defaults to four hours (14400000ms). When it elapses, dispatchRole sends SIGTERM, then SIGKILL after the configured grace window if the child has not exited.

{ "dispatch": { "timeoutMs": 1800000 } }

dispatch.reviewerTimeoutMs

number (optional, positive integer)

The wall-time ceiling for code-reviewer and security dispatches, regardless of agent vendor. Absent defaults to twenty minutes (1200000ms). When it elapses, dispatchRole uses the same SIGTERM-then-SIGKILL escalation as every other dispatch.

{ "dispatch": { "reviewerTimeoutMs": 900000 } }

dispatch.agent

"claude" | "codex" (optional)

A default vendor vinaya dispatch's own --agent flag overrides when given. Absent, --agent is required on the command line.

{ "dispatch": { "agent": "claude" } }

principals

string[] (optional, min 1)

GitHub logins trusted as THIS repo’s own principals — the only authors whose PR comments count as a review-gate verdict, and the only actors an actor-verified vinaya/waiver:docs/vinaya/waiver:review label trusts. There is no built-in default: this list is the whole set of Principals.

Repo-local only, same rule as checks: a global ~/.vinaya/config.json’s principals key is stripped at load time with a loud stderr warning, never resolved — who is trusted to approve merges must come from the reviewed, committed per-repo file, never a machine-wide personal config.

Without this key on the default branch, every Principal-deciding gate and command refuses — review-gate, the waiver labels, and the commands that decide who counts as a Principal (vinaya pr rule, vinaya issue objectives edit, vinaya review post, vinaya review status, vinaya task status, dispatch and the review loop) — naming this key as the fix. Set it to your own team’s GitHub logins.

Read from your repository’s DEFAULT BRANCH via the GitHub API — the value itself never comes from the pull request’s own checkout or local git state, both of which a pull request can rewrite. A PR that edits this field therefore takes effect only once it merges, never for itself. (The API call is addressed using the repository identity the Actions runner provides; that part is not a separate lever, for the reason in the next note.)

⚠️ **This field is only a security control if main has branch protection with the Vinaya check marked as a required status check.** Vinaya’s checks run in a pull_request-triggered workflow, which GitHub executes from the pull request’s own copy of the workflow file — so a PR can always edit or delete the job that runs them. What a PR cannot do is satisfy a required status check that never reports. Without branch protection, principals is a useful team convention, not an enforced boundary. vinaya init prints the exact gh api command to enable it, and vinaya doctor reports when it is missing.

**Set this on your default branch before you need it.** Because the value is read from the default branch, a pull request that introduces principals for the first time is refused by every Principal-deciding check until the key is on the default branch — so land it with the vinaya init install commit, or any push to the default branch, before enabling branch protection. Once it is on the default branch, ordinary review applies: adding a principal thereafter needs an existing principal’s approval, which is the point.

{ "principals": ["alice", "bob"] }

releaseActor

string (optional, min 1)

The GitHub login expected to author THIS repo’s Changesets release PR (branch changeset-release/main) — the identity body-bare-digits checks before treating that PR as machine-rendered, already-reviewed content instead of agent-narrated prose. Overrides the package’s default (github-actions[bot], the identity changesets/action shows when it opens a release PR using the ambient GITHUB_TOKEN).

Set this when your repository opens release PRs some other way — most commonly a custom PAT (e.g. a RELEASE_TOKEN secret) whose owner is a real user login, not a bot. Without it, the release-PR exemption silently never fires for a repo like that, and every release PR stays blocked by its own auto-generated bare version numbers and commit shas.

Same trust class and sourcing rule as principals — repo-local only (a global ~/.vinaya/config.json’s releaseActor is stripped at load time with a loud stderr warning), and read from your repository’s DEFAULT BRANCH via the GitHub API, never from the pull request’s own checkout, local git, or an env var. A PR that introduces or changes this field takes effect only once it merges.

The exemption itself only runs from vinaya-body-checks.yml, a pull_request_target workflow — the same trust boundary principals/review-gate already use. body-bare-digits never trusts this decision from vinaya-checks.yml’s ordinary pull_request job, because that job runs the pull request’s own copy of the workflow file.

{ "releaseActor": "your-release-bot-or-token-owner" }

managed

object (optional)

The ownership manifest vinaya init writes and vinaya eject reads — machine-owned, never adopter-authored. It records exactly what the installer created (files, marker-delimited blocks inside adopter-owned files, created-if-absent labels) so eject reverses precisely: deleting only files it created, stripping only blocks it wrote, reporting labels for manual removal.

If this manifest is absent or corrupt at eject time, eject refuses rather than guessing at ownership. Hand-editing this block is not a supported workflow.

{
  "managed": {
    "version": 1,
    "files": [".github/workflows/vinaya-checks.yml"],
    "blocks": [],
    "labels": ["tier:1"]
  }
}

ci

object (optional)

Adopter-declared CI preparation for the generated workflows. Vinaya’s own checks arrive whole via npx and need no setup; your custom checks are scripts in your repository that may import your repository’s code, and the generated jobs install none of it by default — without this key, any custom check with a dependency fails in CI as a spawn error while passing in the local hooks.

{ "ci": { "setup": "npm ci" } }

ci.setup

string (optional, min 1)

A shell command emitted verbatim as an “Adopter CI setup” step in the generated workflows that execute vinaya check (vinaya-checks.yml, vinaya-review.yml, vinaya-review-verdict.yml — not the archivist, whose jobs never spawn custom checks). It runs after checkout and setup-node, before any vinaya check invocation.

Declared, never inferred: vinaya cannot know your package manager or runtime. Declare whatever your custom checks need to spawn — e.g. npm ci, or a runtime install plus dependency install chained with &&.

When absent, the generated workflows are byte-identical to before this key existed — existing installs see no churn until they both declare the key and run vinaya upgrade. The key is read at generation time (init/upgrade/doctor) from the repo-root config; changing it takes effect on the next vinaya upgrade, which regenerates the managed workflows.

{
  "ci": {
    "setup": "npm install -g bun && bun install --frozen-lockfile --ignore-scripts"
  }
}

tokens

object (optional)

Adopter-declared token-usage collection for vinaya tokens on a non-Claude-Code host — layer 2 of the token-report obligation. Vinaya ships one collection adapter, for Claude Code, which reads that host’s own session transcript; a host with no such transcript has no route to a real Tokens: line without this key.

This is an opt-in collection route, never a capability declaration: vinaya’s metering-capability probe stays host-identity-blind — there is no tokens.metering key, and this key is read only inside vinaya tokens’s own command path, never consulted by the probe vinaya doctor/vinaya upgrade call.

Declaring this key is not enough to make it run: see tokens.collect’s own entry for the explicit machine-local trust gate a declared command must pass before vinaya tokens will ever execute it.

{ "tokens": { "collect": "node scripts/collect-usage.js" } }

tokens.collect

string (optional, min 1)

Exactly "<interpreter> <repo-relative-script-path>" — two whitespace-separated tokens, nothing else (no flags, no extra arguments, no shell syntax, no quoting; security review, PR #303, round 3). vinaya tokens spawns the interpreter directly with the script as its one argument — never through a shell — whenever declared and --in/--out are not given. Its stdout must be a JSON object shaped {"inputTokens":N,"outputTokens":N,"cacheCreationInputTokens":N,"cacheReadInputTokens":N,"model":"…"|null} — the TranscriptSummary seam flattened to JSON.

Declared, never inferred: vinaya cannot know a non-Claude-Code host’s own usage surface (an API response shape, a meter’s CLI, a log format) — same argument as ci.setup, applied to usage collection instead of CI preparation.

When absent, vinaya tokens falls back to the shipped Claude Code transcript adapter unchanged — this key only adds a second route, never removes the first. When declared, a script that fails to run or whose output cannot be parsed fails loudly rather than silently falling back to the transcript route or emitting zeros.

Read from the repo-root config only, same trust class as checks/principals/releaseActor: a value that decides what runs on this turn must come from the reviewed, committed per-repo file — a global ~/.vinaya/config.json’s tokens key is stripped at load time with a loud stderr warning, never resolved.

Unlike ci.setup — which only ever executes inside a generated, reviewed CI workflow step, under the runner’s own isolation — a declared tokens.collect script executes IN-PROCESS, unsandboxed, wherever vinaya tokens runs. Three layers close that gap (security review, PR #303, rounds 1-3): the rigid grammar above (so a declaration can only ever mean one file, never a guess); a trust gate that refuses to run ANY declaration at all until it has been explicitly approved via a one-time vinaya tokens --trust-collect, keyed to the exact interpreter, script path, AND the script’s exact content (a real git hash-object blob hash) together with this repo’s git identity — survives this repo’s own per-task fresh worktrees, but a different interpreter, script path, or so much as one byte of script content, committed or not, needs its own fresh approval; and a printed audit trail — once trusted, the exact interpreter/script is still printed to stderr immediately before every run. Approvals live machine-local at ~/.vinaya/tokens-collect-trust.json, never in any committed file — a pull request can no more grant itself trust than it can add itself to principals.

The script itself runs with a bounded timeout — a stuck or hostile process is killed rather than blocking vinaya tokens forever — and never falls back to the shipped transcript adapter on any failure (untrusted, content changed since approval, timed out, non-zero exit, unparseable output): a declared route that fails is reported as a failure, never silently masked by a different result.

{
  "tokens": {
    "collect": "node scripts/collect-usage.js"
  }
}

In this section

Troubleshooting

What's next