Config Reference
Every key vinaya.config.json accepts, rendered from the one authored registry that documents the schema — never hand-transcribed, and mechanically proven complete against it.
An adopter starting from nothing can write a working vinaya.config.json from this page alone.
rings
Declarative booleans for the two optional rings, on by default. Ring 0 (git hooks) and the CI/branch-protection guarantee are never represented here — they are universal, not configurable — so this object controls only whether the optional rings stay on.
{
"rings": {
"ring1_forgeWriteInterception": true,
"ring2_asyncAudits": true
}
}rings.ring1_forgeWriteInterception
true (the default — an absent key resolves the same way) RUNS forge-write interception: pr/issue create|edit validate a body against briefSchema before any gh write, exactly as they always have. false is the opt-OUT — the only value that changes behavior — and skips that validation entirely.
Prior to issue-545's O2 fix this boolean's sense was inverted (true skipped, false ran) — vinaya upgrade migrates a config still holding the old values and prints what it changed.
{ "rings": { "ring1_forgeWriteInterception": true } }rings.ring2_asyncAudits
true (the default — an absent key resolves the same way) RUNS the async audits: vinaya archive’s provenance work and vinaya audit’s dead-branch-push notification run exactly as they always have. false is the opt-OUT — the only value that changes behavior — and skips that work, exiting 0 without doing anything.
Prior to issue-545's O2 fix this boolean's sense was inverted (true skipped, false ran) — vinaya upgrade migrates a config still holding the old values and prints what it changed.
Deliberately does NOT gate vinaya audit’s direct-main-push detection, which stays unconditional regardless of this flag — it is a real pass/fail that catches a branch-protection bypass, and its own on/off switch must never be readable from ordinary, PR-reachable config content (the security-review reasoning: the actor a detector exists to catch must never also be able to disable it in the same push).
{ "rings": { "ring2_asyncAudits": true } }checks
Custom-check registration, keyed by check name. Each entry produces the exact same CheckSpec shape the built-in registry does — no field either side can carry that the other cannot.
A key that exactly matches a core check id is an **override attempt** (replaces the core check, contract-validated, fail-closed if malformed); any other key must be namespaced <yourname>/<id> (exactly one /, both segments [a-z0-9][a-z0-9-]*, the vinaya prefix reserved). A bare key matching no core id is a config error.
Globs (include) are permitted for scoping; conditionals (if/unless/except) are never part of this grammar. Registered from a repo-local vinaya.config.json only — a global ~/.vinaya/config.json’s checks key is stripped at load time with a loud stderr warning, never resolved.
{
"checks": {
"myteam/vocab-check": {
"run": "./scripts/vinaya-checks/vocab-check.ts",
"scope": "diff",
"include": ["**/*.md"]
}
}
}checks.run
The executable path (or bare command on PATH) the runner spawns directly — never through a shell.
{ "run": "./scripts/vinaya-checks/vocab-check.ts" }checks.scope
diff checks may be skipped by the runner under --diff-only when no changed file matches include. full checks (coherence, dispatch-readiness) always run — they read live forge state, not the local diff.
{ "scope": "diff" }checks.include
Glob patterns that scope a diff-scoped check to changed files. Scoping only — never a conditional.
{ "include": ["**/*.md", "apps/**/*.ts"] }checks.args
Extra argv elements passed to run, appended after the runner’s own fixed arguments.
{ "args": ["--strict"] }checks.timeoutMs
Advisory to the check; the RUNNER enforces the actual deadline (kills the whole process group), never the check itself.
{ "timeoutMs": 30000 }checks.requiresOpenPr
Marks a check that can only meaningfully evaluate once a pull request exists — it reads the real PR body/number, not local git state. The generated pre-commit/pre-push hooks (vinaya check --all --local) skip a check declaring this entirely rather than running it against a PR that cannot exist yet; CI (which only ever runs after a PR is open) always runs it for real.
Use for a custom check with the same shape as the core closes-n/test-plan checks — anything that would otherwise deadlock the first commit on a fresh branch by requiring PR content before a PR can exist.
{ "requiresOpenPr": true }checks.ownWorkflow
Marks a check that a dedicated workflow of your own already reports. vinaya check --all omits it, so the same check is never evaluated twice under two different job names. Naming the check directly (vinaya check <name>) still runs it.
Use it when a check's answer can change AFTER a push — for example one that reads pull-request comments. Such a check needs its own workflow that something re-runs when the input changes; a second copy inside --all is never re-run, so it freezes at push-time state and reports a stale result forever. The core review-gate check is exactly this shape and carries this flag.
{ "ownWorkflow": true }checks.principalOwed
Marks a check whose failure only the Principal can clear — it reads a state only a human review/verification step produces, no diff the Developer pushes can satisfy it directly. The mechanical gate (vinaya check --all's own exit code, and the loop's CI reader) excludes this check's failure from what makes a run red ONLY when every error it reported that run is a pending wait-state — a structural failure on the same check still counts. Enforcement of the human-owed half itself does not disappear: it moves to review-gate, which refuses merge while the live PR body still carries an unticked [principal] item.
Same no-privileged-field discipline as requiresOpenPr and ownWorkflow — a config-registered check declares this exactly like the core test-plan check does.
{ "principalOwed": true }checks.validates
Names what forge-write object this check validates the outgoing bytes of. 'body' marks a check vinaya pr create/pr edit/pr report --push (and the Issue write paths' own body checks) run automatically, through the same registry runner, over the not-yet-shipped bytes before they ever reach the forge — a check declaring this needs no further wiring in the write path itself. 'issue' marks a check that only ever applies to a task Issue's own content (never a pull request); it runs at Issue-write time and in the coherence sweep over open Issues, and is never selected into a pull-request workflow.
Same no-privileged-field discipline as requiresOpenPr/ownWorkflow/principalOwed — a config-registered check declares this exactly like a core one, and the write path treats every check with this value identically regardless of origin. Absent (the default) for a check that grades the code diff itself, never forge-write content.
{ "validates": "body" }checks.env
The per-check environment allowlist. A check’s child process receives ONLY a fixed safe baseline (PATH, LANG, HOME, HTTPS_PROXY, HTTP_PROXY, NO_PROXY, TMPDIR) plus whatever this declaration explicitly forwards — never the full parent environment.
A check that reads process.env/Bun.env/Deno.env directly with no env declared is invisible to the child process; vinaya doctor carries the permanent diagnostic for that gap. Four declared forms, below.
{
"env": {
"JIRA_TOKEN": { "anyOf": ["JIRA_TOKEN", "JIRA_PAT"] }
}
}checks.env.passthrough
Required passthrough — forwards the caller’s own value for this key verbatim. Absent from the caller’s environment synthesizes a CheckError before the check ever spawns.
{ "env": { "GITHUB_TOKEN": true } }checks.env.optional
Passthrough if set, simply absent from the child otherwise — never fatal. Use for a var the check’s own code already tolerates missing (e.g. a CI-only secret that would otherwise hard-block git push on a developer laptop).
{ "env": { "PR_BODY": { "optional": true } } }checks.env.anyOf
An either/or requirement with no deeper fallback — at least one named member must be set in the caller’s environment, or a CheckError is synthesized before spawn. Every set member passes through under its own name.
The record key must be one of its own anyOf members, and at least 2 unique members are required (a single-member anyOf is just true under a different name). Adopter-facing only — no core check uses this form, since every core check’s env read already has a deeper fallback (a gh auth token subprocess, a default literal) that an anyOf’s hard pre-spawn failure would be wrong for.
{ "env": { "JIRA_TOKEN": { "anyOf": ["JIRA_TOKEN", "JIRA_PAT"] } } }checks.env.literal
Sets the key to this exact value, never interpolated — spawn takes an explicit env object, no shell expands $VAR.
{ "env": { "NODE_ENV": "test" } }Never put a secret in a literal. env values live in a COMMITTED file, reviewed like any other code change — a literal is for fixed, non-sensitive values only (e.g. "NODE_ENV": "test"), never a token or credential. vinaya doctor warns on a high-entropy literal (looks like a leaked secret) and on a literal "true"/"false" string (almost certainly meant as the boolean passthrough form instead) as a backstop, but review is the real defense — do not rely on the linter to catch every case.
roles
Per-role override and additive-role registration, keyed by config key. Each entry names a contract: a markdown file, structurally validated the same way a core role doc is — six frontmatter keys (role_id, description, actor, performs, refuses_when, summary), plus title and order, plus a non-empty "## The short version" body section.
A key that exactly matches a core role id is an **override attempt** (a COMPLETE replacement of that role — never a patch, no frontmatter inheritance — whose contract's own role_id must equal the config key exactly); any other key must be namespaced <yourname>/<id> (exactly one /, both segments [a-z0-9][a-z0-9-]*, the vinaya prefix reserved) and is **additive**, whose contract's own role_id must equal the key's post-"/" segment exactly. A bare key matching no core role id is a config error.
Unlike checks, there is deliberately NO grace period for a malformed entry — roles config is wholly new, with no legacy population a warn window would need to keep alive, so every malformed variant fails closed from day one.
An additive role's own role_id (its "render id" — what every renderer, vinaya check --plan's RENDERS AS column included, actually shows) must not collide with a core role id or with another additive role's render id; either collision is a config error naming both colliding keys.
Registered from a repo-local vinaya.config.json only — a global ~/.vinaya/config.json's roles key is stripped at load time with a loud stderr warning, never resolved, because a role contract is agent-facing doctrine (vinaya doctrine --role hands it to a third-party agent tool as operating instructions), and which contract a role name resolves to must come from the reviewed, committed repo file.
{
"roles": {
"developer": { "contract": "./roles/custom-developer.md" },
"acme/qa-lead": { "contract": "./roles/qa-lead.md" }
}
}roles.contract
A PATH to the role's markdown contract, resolved relative to this vinaya.config.json's own directory. PATH-ONLY: a slash-free value (a bare filename like "developer.md") is rejected at load — it must read as a path, never a bare role id.
{ "contract": "./roles/custom-developer.md" }briefSchema
The config-defined brief schema the forge-write commands (pr create|edit, issue create|edit) validate a body against, locally, before any gh write. Declarative only — a required section is either a named battle-tested builtin or a generic heading/field/phrase matcher; no conditional grammar.
{
"briefSchema": {
"pr": { "sections": [{ "builtin": "tier" }, { "heading": "Rollback Plan" }] }
}
}briefSchema.pr
Required sections for a PR body — checked by vinaya pr create|edit before any gh write.
{ "pr": { "sections": [{ "builtin": "closesN" }] } }briefSchema.issue
Required sections for an Issue body — checked by vinaya issue create|edit before any gh write.
{ "issue": { "sections": [{ "builtin": "issueRationale" }] } }briefSchema.milestone
Required sections for a Milestone body — checked by vinaya milestone create before any gh write, same shape as briefSchema.pr/briefSchema.issue.
checkMilestoneShape's own refusal (goal absent, Release: present but malformed, the ### Tranche intents section unparseable) is unconditional and runs whether or not this key is set — mirroring the Issue-only content gate (checkBlastRadiusScope/checkNoBriefContent/checkRationaleNamesDocs), config decides which EXTRA sections are required, never whether that check runs. This key only adds an adopter's own custom heading/field/phrase sections, or opts into the milestoneShape builtin explicitly for the same check surfaced through this path too.
{ "milestone": { "sections": [{ "builtin": "milestoneShape" }] } }briefSchema.ack
Builtin names this repo has deliberately dropped from briefSchema.pr/briefSchema.issue. Purely a silencer for vinaya doctor's brief-schema divergence report — it grants nothing, gates nothing, and acking a builtin that is still declared changes no behaviour.
briefSchema is yours: vinaya upgrade never rewrites it. That also means a builtin deleted as a workaround stays deleted and, without this report, stays invisible — no command surfaces it and no later upgrade repairs it. doctor reports the divergence at info severity, so it never fails anyone's CI; list a name here once the omission is a considered choice, and an accidental one keeps surfacing.
{ "briefSchema": { "ack": ["closesN"] } }managed
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"]
}
}principals
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. Overrides the package’s hardcoded default principal (this monorepo’s own maintainer) entirely — a full replacement, not additive.
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, vinaya’s hardcoded default principal is the only trusted author — which makes review-gate structurally unpassable on any repo that principal doesn’t personally review. Set this to your own team’s GitHub logins to make the gate passable on your repo.
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 still evaluated against vinaya’s built-in default — so its own author cannot yet approve it, and cannot self-waive either (the waiver labels resolve through the same list). 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
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" }ci
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
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
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
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"
}
}blastRadius
checkBlastRadiusScope’s collision-domain declaration surface — the sanctioned "I need one more domain" path. The legacy static .aeg/packages file is retired; this is the only way to declare a domain beyond live derivation. Every packages/* workspace member is derived live at check time (from package.json’s workspaces, or pnpm-workspace.yaml’s packages: list on a pnpm repo), and a built-in default set covers the common cross-cutting paths by presence-check (whichever lockfile exists, turbo.json/biome.json/tsconfig.json, .github/workflows, .husky) — this key is for anything beyond those two.
{ "blastRadius": { "extraDomains": ["migrations", "packages/generated"] } }blastRadius.extraDomains
Repo-relative path prefixes treated as additional shared collision domains — a migrations/ folder, a codegen output directory, anything that couples tasks across package boundaries with no universal naming convention checkBlastRadiusScope’s built-in defaults can presence-check.
{ "extraDomains": ["migrations"] }projects
A config-native home for project metadata, alongside — not instead of — .vinaya/projects.md (the project registry). vinaya init product <name> appends an entry here at the same time it appends the registry row.
Minimal metadata only: name (required, the dedup key — matches the registry row's own Project column), description (optional), path (optional). Display metadata, never load-bearing for enforcement — no gate or resolver reads this key.
Absent entirely for a single-project repo, or for any repo that has never run init product. vinaya doctor reports (at info severity, never an error) when a registry row and a projects entry name the same project but only one of the two exists.
{
"projects": [
{ "name": "mobile", "path": "apps/mobile", "description": "The mobile client" }
]
}proseGates
De-hardcodes the two prose/vocabulary core checks — reader-resolvable-prose and retired-vocabulary — behind adopter configuration, so both can run for real in an adopter repo instead of only inside this monorepo's own dev loop. Read fresh on every vinaya check run (not at generation time), so an edit takes effect on the very next run with no vinaya upgrade needed.
Every field is optional; unset entirely, both checks behave exactly as they did when this key did not exist — this repo's own prior hardcoded doctrine layout.
Both checks are report-only for the classes these fields configure — a finding prints as a warning, and the check's own exit code stays 0 for them — except reader-resolvable-prose's two blocking classes, which fail with severity: 'error' and a non-zero exit: a tranche-slug citation in product code (a fixed PRODUCT_SLUG_SCOPE, not configurable by this key), and any citation in a spec-class file (specPaths below, whose defaults need no configuration at all; specGrandfather is its only exemption).
{
"proseGates": {
"doctrineRoot": "governance-docs",
"readerFacingPrefix": "apps/web/src/app/(site)",
"readerFacingSuffix": "/page.tsx",
"legacySlugDir": "governance-docs/tranches/completed"
}
}proseGates.doctrineRoot
The doctrine directory both checks sweep in full — every .md under it counts as "ships" prose. Defaults to "aeg-root", this repo's own doctrine root. An adopter who names their installed doctrine tree differently sets this once; both checks read the same value.
{ "doctrineRoot": "governance-docs" }proseGates.readerFacingPrefix
The path prefix of the adopter's reader-facing surface (a public site, docs app, …) that reader-resolvable-prose also sweeps. Must be set TOGETHER with readerFacingSuffix — either alone is a declared no-op, not a partial sweep, matching this repo's own dormant default (no public site here).
{ "readerFacingPrefix": "apps/web/src/app/(site)" }proseGates.readerFacingSuffix
The filename suffix (e.g. "/page.tsx") that, combined with readerFacingPrefix, selects which files under the reader-facing tree actually carry reader-visible prose — sibling files (components, fixtures) are not swept.
{ "readerFacingSuffix": "/page.tsx" }proseGates.legacySlugDir
The archived-tranche directory reader-resolvable-prose's legacy-slug citation class derives its slug list from (filenames only, never content). Defaults to "<doctrineRoot>/tranches/completed". Absent on disk degrades this class to explicitly dormant, never an error.
{ "legacySlugDir": "governance-docs/tranches/completed" }proseGates.sourceComments
reader-resolvable-prose's source-comment class: scans comment lines of .ts files for a tranche-slug ([a-z0-9]+(-[a-z0-9]+)*-v[0-9]+) or forge-number (#[0-9]{2,}) citation. Unset entirely, the class is dormant — the same declared-no-op discipline readerFacingPrefix/readerFacingSuffix use.
Report-only (severity: "warning", exit 0) by default. This repository sets severity: "error" once its own sweep reached zero findings, so a new citation now fails check --all the same way the product class always has.
{
"proseGates": {
"sourceComments": {
"globs": ["apps/cli/src", "packages/aeg-core/src"],
"allowlist": ["packages/aeg-core/src/golden-forge-vs-file.test.ts"],
"severity": "error"
}
}
}proseGates.sourceComments.globs
Repo-relative directory (or file) roots swept recursively for .ts files — a prefix match, like PRODUCT_SLUG_SCOPE, never a shell glob pattern despite the field's name. Unset (the default) leaves the class dormant: no directory is swept.
{ "globs": ["apps/cli/src", "packages/aeg-core/src", "packages/aeg-forge-state/src", "packages/sources/src"] }proseGates.sourceComments.allowlist
Exact repo-relative file paths skipped entirely by the source-comment class — for a test fixture that deliberately pins a historical tranche name or forge number in a comment, where rewriting the citation away would break the thing the fixture exists to prove. Not a pattern; every entry is a full path.
{ "allowlist": ["packages/aeg-core/src/golden-forge-vs-file.test.ts"] }proseGates.sourceComments.severity
Defaults to "warning" (reports, exit 0) — the same rollout precedent every class in this key follows. "error" fails the check's exit code on a reportable finding, same as the product class already does unconditionally.
{ "severity": "error" }proseGates.specPaths
Repo-relative files or folders ADDED to reader-resolvable-prose's spec class — the blocking class that refuses a citation only this repository's own tracker can resolve. A folder entry is swept recursively for .md files; a file entry names that one file.
Additive: the class already reads, with no configuration at all, a root SPEC.md, a root CONTEXT.md, every .md under docs/adr/, and every apps/<app>/specs/**/*.md. A repository carrying none of those files has nothing new to report. Setting this key adds to that set and never replaces it.
The class blocks four shapes in every file it reads: an Issue or PR number (#NNN), an internal tranche slug (<slug>-vN), an archived-tranche slug, and a task number — the word task or tasks followed by a number (task 4, tasks 11, task #7), which a durable document copies out of a plan and which goes stale the moment that plan is renumbered. A document's own numbered structure ("Section 3", "step 2") is never a match.
README.md is deliberately not a default and should not be added lightly: the tranche-slug pattern matches an ordinary stack badge, which is not a citation at all.
Each entry names a path inside the repository. A trailing slash or a leading ./ is accepted and reduced to the same bare path, so docs/adr/, ./docs/adr and docs/adr all name one folder. An entry that leaves the repository — absolute, or climbing through .. — is refused when the configuration is read, since this class reads the repository under check and nothing else; a folder that is a symlink pointing outside is read through by nothing either.
{
"proseGates": {
"specPaths": ["docs/architecture", "PRODUCT.md"]
}
}proseGates.specGrandfather
Exact repo-relative paths of pre-existing spec prose that already carried citations when the spec class started reading it, so the class can block on day one without failing every open pull request against that backlog. Not a pattern; every key is a full path.
The object form maps each path to the most spec-class findings that file may carry — the count the check reports for it today. A listed file with more findings than its number fails, naming the file, its count and its limit; a file at or below its number passes. A listed file therefore can never gain a citation, and its number is lowered as the document is rewritten to state its facts plainly, down to 0.
The array form exempts each listed file entirely, from every rule the class runs, the task-number one included, and the check prints one warning per entry saying so — a file on it can gain any number of citations and still pass. Either form covers a file the defaults brought in (a root spec, a decision record) exactly as it covers one under an app's own specs/**.
{ "specGrandfather": { "apps/cli/specs/loop.md": 130 } }dispatch
vinaya dispatch <role> --agent claude|codex|gemini (apps/cli/src/lib/dispatch.ts) settings: the wall-time ceiling before the headless child is signaled, and a default vendor for repos that always dispatch the same one.
{
"dispatch": {
"timeoutMs": 3600000,
"agent": "claude"
}
}dispatch.timeoutMs
The wall-time ceiling for one dispatched agent process. Absent defaults to one hour (3600000ms). When it elapses, dispatchRole sends SIGTERM, then SIGKILL after a fixed grace window if the child has not exited.
{ "dispatch": { "timeoutMs": 1800000 } }dispatch.agent
A default vendor vinaya dispatch's own --agent flag overrides when given. Absent, --agent is required on the command line.
{ "dispatch": { "agent": "claude" } }reviewPolicy
Which severities block is repository policy, not a hardcoded literal: one threshold per review role's own ordered severity scale — code review over BLOCKER > MAJOR > MINOR, security review over CRITICAL > HIGH > MEDIUM > LOW. A finding at or above the threshold prevents approval everywhere a verdict is derived, accepted, or judged. Also carries the dev-review-loop's own two bounds on a task: its round cap (maxRounds) and its wall-clock budget (maxTaskMinutes), both below.
A finding whose own location is the PR body, a comment, or a role file is capped to MINOR before it counts toward either threshold, unconditionally — never configurable, never a source or test file. Prose alone never blocks a merge.
Omitted entirely, or any field omitted, defaults to today's behavior (BLOCKER / HIGH / 3 rounds / 180 minutes). An unknown severity name, a maxRounds that isn't a positive integer, or a maxTaskMinutes that isn't a non-negative integer, refuses at config load — it never silently falls back, unlike the rest of this config's fields.
Read only via the default branch's configuration (the same trust class as principals), never the pull request's own checkout, so a change cannot lower its own threshold.
{
"reviewPolicy": {
"codeReviewThreshold": "MAJOR",
"securityThreshold": "HIGH",
"maxRounds": 5,
"maxTaskMinutes": 240
}
}reviewPolicy.codeReviewThreshold
One of BLOCKER, MAJOR, MINOR (code review's own ordered scale). Defaults to BLOCKER when omitted. Any other value refuses config load with the accepted scale named in the error.
{ "reviewPolicy": { "codeReviewThreshold": "MAJOR" } }reviewPolicy.securityThreshold
One of CRITICAL, HIGH, MEDIUM, LOW (security review's own ordered scale). Defaults to HIGH when omitted. Any other value refuses config load with the accepted scale named in the error.
{ "reviewPolicy": { "securityThreshold": "HIGH" } }reviewPolicy.maxRounds
The dev-review-loop's own round cap — replaces a hardcoded constant. Defaults to 3 when omitted: rounds 1 to 3 run, and a round that reaches the cap without going green pauses max_rounds, naming the configured value, so no further round runs without a Principal ruling; a green round at the cap still publishes. Any non-positive-integer value refuses config load rather than falling back.
{ "reviewPolicy": { "maxRounds": 5 } }reviewPolicy.maxTaskMinutes
The dev-review-loop's own wall-clock budget for ONE task, in minutes. Defaults to 180 when omitted. Measured from the loop's first recorded start for that task — read from the durable control records, so a driver that was killed, restarted, or re-execed itself continues the same budget rather than starting a fresh one.
Checked at every round boundary and every mechanical retry, so a task stuck short of review cannot run past the budget by more than the one attempt in flight when it ran out. Over budget pauses time_budget, and the pause names the budget, the time spent, and where that time went phase by phase.
This bounds what maxRounds cannot: the round cap counts review rounds, and a loop spending hours pushing, rebasing and retrying advances no round at all, so the cap it would eventually hit is one it never reaches.
0 turns the budget off — the one value here that removes a bound rather than tightening it, for a repository that would rather bound rounds only. A negative or fractional value refuses config load rather than falling back.
{ "reviewPolicy": { "maxTaskMinutes": 240 } }securityScan
The agent-configuration security scanner the dev-review-loop runs before the security pass. When a pull request touches agent configuration (.claude/**, .mcp.json, .agents/** — a fixed list, never configurable) and this key is set, the driver runs the scanner once per round on the head-verified candidate copy, in a constructed environment carrying no forge credential, with a time limit and an output cap, and hands the result to the security reviewer's prompt as input to its judgement — never the verdict.
The scan runs outside the driver's trust: the security reviewer itself is denied npx and never runs a scanner. Absent, the security pass is told no scanner is configured and the round proceeds unchanged — a missing, not-applicable, or failed scan never pauses the loop.
Read only from the default-branch trust anchor (the same trust class as reviewPolicy/principals), never the pull request's own checkout: the key names a subprocess that runs in the driver's environment, so a pull request must not be able to redirect it in its own diff.
{
"securityScan": {
"command": ["npx", "--yes", "ecc-agentshield@1.6.0", "scan"]
}
}securityScan.command
The scanner as an argv list — its first element the executable, the rest its arguments, the package version pinned in the args (["npx", "--yes", "ecc-agentshield@1.6.0", "scan"]). The loop appends the directory to scan (the head-verified candidate copy) as a final argument and spawns it via execFile, never a shell, so no element is shell-interpreted. A completed run's output reaches the reviewer verbatim; its exit code is not read as a verdict.
{ "securityScan": { "command": ["npx", "--yes", "ecc-agentshield@1.6.0", "scan"] } }gateCutovers
The Issue/PR number below which each of five gates is grandfathered — an Issue or pull request older than a gate's cutover passes it unconditionally. Every field is optional; **an absent key, or an absent field within it, means NO cutover for that gate — it applies to every Issue/PR from number 1.** A brand-new repository declares none, so its Issue 1 is refused without ## Objectives, the brief sections, and ## Documentation exactly as a high-numbered Issue is, and its pull requests are held to the brief-shape rules from PR 1.
This key exists ONLY for a repository whose Issues or pull requests PREDATE a gate: set it to the number from which that gate first applied, so the older stock keeps passing while everything from the cutover on is enforced. A repository that has always had the gates leaves this unset. The values were once hardcoded constants inside @attalabs/aeg-core (OBJECTIVES_SINCE_ISSUE etc.); moving them here is what lets every adopter's gates apply from Issue 1 by default.
Read from the working-tree vinaya.config.json (not the default-branch trust anchor) by the Issue-write, coherence, and brief-shape gates, and from the default-branch trust anchor by the review-gate's own objectives grading (the same source as reviewPolicy/principals) — these are content-shape cutovers of the same reviewed-committed trust class as the source constants they replace, never a merge-authority lever like reviewPolicy.
{
"gateCutovers": {
"objectivesSinceIssue": 404,
"briefSectionsSinceIssue": 426,
"documentationSinceIssue": 626,
"briefRulesSincePr": 394,
"agentBoxesRefusedSincePr": 396
}
}gateCutovers.objectivesSinceIssue
The Issue number from which the ## Objectives gate (checkIssueObjectives, vinaya issue create|edit, and vinaya check coherence’s R1) applies. Absent → no cutover: every task Issue must carry ## Objectives, from Issue 1.
{ "gateCutovers": { "objectivesSinceIssue": 404 } }gateCutovers.briefSectionsSinceIssue
The Issue number from which the four judgment sections (## Surface, ## Parts, ## Test plan, ## Stop conditions, via checkIssueBriefSections) are required. Absent → no cutover: required from Issue 1.
{ "gateCutovers": { "briefSectionsSinceIssue": 426 } }gateCutovers.documentationSinceIssue
The Issue number from which ## Documentation is required (folded into checkIssueBriefSections). Absent → no cutover: required from Issue 1. Typically higher than briefSectionsSinceIssue, since ## Documentation shipped later.
{ "gateCutovers": { "documentationSinceIssue": 626 } }gateCutovers.briefRulesSincePr
The pull-request number from which the four brief-shape rules (unpinned code claim, commands-carry-output, consumer-tests, defeat-cases) block in CI (brief-shape / partitionBriefErrorsByRollout). A PR below it has those findings reported as informational, never a failure. Absent → no cutover: the rules block from PR 1.
{ "gateCutovers": { "briefRulesSincePr": 394 } }gateCutovers.agentBoxesRefusedSincePr
The pull-request number from which a checkbox [agent] Test Plan item is refused (checkNoAgentBoxes, via the same brief-shape rollout). A PR below it is grandfathered. Absent → no cutover: refused from PR 1. A distinct number from briefRulesSincePr — each rule keeps its own rollout window.
{ "gateCutovers": { "agentBoxesRefusedSincePr": 396 } }planning
Plan-time policy — what the Planner-facing gates in vinaya issue create/vinaya issue edit enforce about a task Issue before it reaches the forge.
{
"planning": {
"collisionThreshold": 3
}
}planning.collisionThreshold
How many files two tasks may have in common before the one being written must declare a Conflicts-with edge on the other. vinaya issue create/vinaya issue edit compare this Issue's Boundary Pinned files: against every other open task Issue's pinned files and every open pull request's changed files: at or above this many shared files, with no Conflicts-with edge declared in either direction, the write is refused, naming the other task and the shared files.
Under the threshold the Issue is accepted and each shared file is printed as a warning, with the task it is shared with — a small overlap is worth running in parallel, because a merge conflict over one or two files costs minutes while serializing a task costs a whole dispatch.
0 turns the refusal off entirely and leaves only the warning. Absent → 3. The same comparison runs when a task is dispatched, scoped to open pull requests only; only the first fifty open pull requests are read, and the output says so when that bound was reached.
{ "planning": { "collisionThreshold": 3 } }prePush
The pre-push hook's test-file selector (lib/test-selector.ts) chooses what to run by import-graph reachability from the diff — a rule whose own input is the repository itself (a generated file, an exec bit, a manifest re-derived from the repo tree) is never reached by that graph, so it never runs at push time on its own merits. This object is the escape hatch.
{
"prePush": {
"alwaysRun": ["apps/cli/tests/ci-shards.test.ts"]
}
}prePush.alwaysRun
Glob patterns (matched against a test file's repo-root-relative path) naming test files that run on EVERY push regardless of reachability — additive only, on top of whatever the import graph already selects, never a narrowing of it. Every test file added or renamed in the diff also runs unconditionally, with no config needed for that half.
{
"prePush": {
"alwaysRun": [
"apps/cli/tests/ci-shards.test.ts",
"apps/cli/tests/checks/changeset-coverage*.test.ts"
]
}
}report
Policy for vinaya pr report's evidence runner (Group C, the Test Plan's [agent] fenced command list).
{ "report": { "commandTimeoutMs": 900000 } }report.commandTimeoutMs
The wall-time budget, in milliseconds, for each [agent] Test Plan command the evidence runner executes. Defaults to 900000 (15 minutes), capped at 3600000 (1 hour) — a config load refuses a value above the cap. A command that exceeds it is recorded in AEG:EVIDENCE as timeout alongside the budget it exceeded, never silently dropped — raising this key up to the cap is the sanctioned way to give a genuinely slow command more room; the runner never reads a bigger number from anywhere else.
{ "report": { "commandTimeoutMs": 1800000 } }logPublish
Removed: telemetry is never posted to a tracker or a code host in any form, so the comment-posting flush and its issue/pr/webhookUrl destinations are gone. The key is still declared — refused, not silently stripped — so a config that still carries it fails config load loudly, naming logs as its replacement, rather than silently losing the setting.
{ "logs": { "url": "https://ingest.example.com/vinaya" } }runtimeDir
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" }logs
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" } }
}
}
}check --plan --json
The resolved check registry's JSON envelope — what reading the config back out, after override/additive resolution, looks like. This page is its documented home.
| Field | Type | Meaning |
|---|---|---|
schema | 1 | The envelope version. Additive evolution only — a field is never removed or retyped under the same version number. |
checks | Record<name, { state, source, env, envAnyOf?, scope }> | The fully resolved check registry, keyed by name — every core and config entry, after override/additive resolution. |
checks.<name>.state | 'default' | 'overridden' | 'additive' | `default`: shipped with Vinaya, unmodified. `overridden`: a config entry currently claims this (core) id and satisfies its contract. `additive`: a wholly new, namespaced entry. |
checks.<name>.source | 'core' | 'config' | Where the resolved spec came from — `registry.ts` (`core`) or `vinaya.config.json` (`config`). |
checks.<name>.env | Record<string, 'passthrough' | 'optional' | 'literal' | 'anyOf'> | How each declared env var resolves — never the actual value. A security reviewer auditing the plan needs to see "reads the caller’s real token" vs. "sets a fixed string," never the token or string itself. |
checks.<name>.envAnyOf | Record<string, string[]> (optional) | Present only for `anyOf`-labeled env keys — the full member list for that key. |
checks.<name>.scope | 'diff' | 'full' | The resolved check’s scope, echoed from its `CheckSpec`. |
roles | { available: true, resolved: Record<name, {...}>, errors: RoleResolverFailure[] } | { available: false, reason: string } | `available: false` only when no bundled doctrine can be found next to this CLI install (nothing to resolve core roles against) — `reason` names why. Otherwise `available: true`, with the fully resolved role registry. |
roles.resolved.<name>.state | 'default' | 'overridden' | 'additive' | `default`: a core doctrine role, unmodified. `overridden`: a config entry currently claims this (core) role id and satisfies its contract. `additive`: a wholly new, namespaced role. |
roles.resolved.<name>.source | 'core' | 'config' | Where the resolved contract came from — bundled doctrine (`core`) or `vinaya.config.json`'s `roles` (`config`). |
roles.resolved.<name>.rendersAs | string | The role's own `role_id` — the identifier every downstream consumer actually sees. Equal to `<name>` for `default`/`overridden`; the post-"/" segment of `<name>` for `additive` (the registry-id/render-id decoupling that lets `acme/qa-lead` register under that whole key but render as `qa-lead`). |
roles.resolved.<name>.title | string | The resolved contract's own `title` frontmatter. |
roles.resolved.<name>.gating | 'core' | 'inert' | `core` for every `default`/`overridden` entry — it participates in core enforcement (doctrine's own `ACTIONS.performedBy` wiring). `inert` for every `additive` entry — documentation-only, since no core `ACTIONS` entry can name a render id core doctrine never declared. |
roles.errors | RoleResolverFailure[] | Every role-resolution failure (a malformed `roles` entry, a `role_id` mismatch, a render-id collision, a bare key matching no core role id) — rendered inline, never dropped. Non-empty `roles.errors` makes `--plan`'s own exit code non-zero, same as a non-empty top-level `errors` does for `checks`. |
roles.reason | string | Present only when `roles.available` is `false` — why no doctrine could be resolved. |
errors | ResolverFailure[] | Every `FAIL_CLOSED` entry (a bare key with no namespace matching no core check) — rendered inline, never dropped. Non-empty `errors` always exits non-zero; `--plan` never swallows a failure to render a clean-looking table. Non-empty `errors` is also what real execution refuses on: `vinaya check` runs NOTHING while any entry is unresolvable, so a non-empty `errors` here is a preview of a refused run, not an advisory. |