Docs

Reference

Vinaya keys

The keys the Vinaya process declares in vinaya.config.json: its roles, brief shape, reviews, checks and limits.

vinaya.config.json
Process keys
Reference

The process keys

roles

Record<string, RoleEntry> (optional)

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

string

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

object (optional)

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

{ sections: BriefSection[] } (optional)

Required sections for a PR body — checked by vinaya pr create|edit before any gh write.

{ "pr": { "sections": [{ "builtin": "closesN" }] } }

briefSchema.issue

{ sections: BriefSection[] } (optional)

Required sections for an Issue body — checked by vinaya issue create|edit before any gh write.

{ "issue": { "sections": [{ "builtin": "issueRationale" }] } }

briefSchema.milestone

{ sections: BriefSection[] } (optional)

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

BriefBuiltin[] (optional)

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"] } }

reviewPolicy

object (optional)

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 bounds on a task: its round cap (maxRounds), its wall-clock budget (maxTaskMinutes) and its ready push's test-run limit (readyPushTimeoutMinutes), each 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 / 20 minutes). An unknown severity name, a maxRounds or readyPushTimeoutMinutes 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

string (optional)

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

string (optional)

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

number (optional, positive integer)

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

number (optional, non-negative integer, minutes)

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 } }

reviewPolicy.readyPushTimeoutMinutes

number (optional, positive integer, minutes)

How long the version-2 ready push's test run may take, in minutes, before it is stopped. Defaults to 20 when omitted. A large core change can select over a hundred test files, which one machine runs serially while CI shards the same work, so a repository or a machine that needs longer raises it.

Read from the default branch like maxRounds, so a change cannot raise its own limit. A run stopped at the limit is never sent back to the Developer as a test failure: the loop pauses for a person, naming the limit in minutes and the number of selected test files, and the approved code is left unchanged.

Like the other limits, it is left out of the policy digest verdicts bind to: it bounds how long the loop runs, never whether a finding blocks. A value that is not a positive integer refuses config load rather than falling back.

{ "reviewPolicy": { "readyPushTimeoutMinutes": 60 } }

planning

object (optional)

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

number (optional, non-negative integer)

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 } }

projects

ProjectEntry[] (optional)

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" }
  ]
}

blastRadius

object (optional)

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

string[] (optional)

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"] }

checks

Record<string, CheckEntry> (optional)

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

string

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' | 'full'

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

string[] (optional)

Glob patterns that scope a diff-scoped check to changed files. Scoping only — never a conditional.

{ "include": ["**/*.md", "apps/**/*.ts"] }

checks.args

string[] (optional)

Extra argv elements passed to run, appended after the runner’s own fixed arguments.

{ "args": ["--strict"] }

checks.timeoutMs

number (optional)

Advisory to the check; the RUNNER enforces the actual deadline (kills the whole process group), never the check itself.

{ "timeoutMs": 30000 }

checks.requiresOpenPr

boolean (optional)

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

boolean (optional)

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

true (optional)

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

'body' | 'issue' (optional)

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.advisory

true (optional)

Marks a check whose findings are reported as warnings and never fail a run — its own code exits 0 on every finding; only a configuration refusal (no Principal configured) can still exit non-zero. vinaya check --plan shows it in an ADVISORY column (and as advisory: true in --plan --json), and each result line vinaya check prints ends with — advisory, so a passing advisory check is never read as a gate that held.

A label only: it changes nothing about how the check runs or how a run's exit code is computed. Same no-privileged-field discipline as principalOwed/validates — a config-registered check declares this exactly like the core doc-coverage-push, quoted-command, doctrine-portability and workspace-escape checks do.

{ "advisory": true }

checks.env

Record<string, EnvEntry> (optional)

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

true

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

{ optional: true }

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

{ anyOf: string[] }

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

string

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.

rings

object (optional)

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

boolean

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

boolean

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 } }

prePush

object (optional)

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

string[] (optional)

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"
    ]
  }
}

prePush.readyTests

{ mode: 'ci', workflow: '.github/workflows/ci.yml', check: 'Vinaya CI' } (optional)

Opts a self-hosting repository's version-2 ready push, and the driver-owned repair pushes on its own open pull request, into required-CI testing: those pushes keep lint, every registered local check and the affected typecheck, and defer the automatic selected test suite to the named required check on the approved current head. The run reaches ready-for-merge only once that check, in that workflow's run for that head, completed successfully.

Read from the default branch only — never from a task branch, a local override or an environment value — and a failed read refuses rather than deferring. Absent means local tests. The one supported value is the pair shown; any other value is refused. Approved explicit Test Plan commands still run locally, through the driver, on the head being published.

{
  "prePush": {
    "readyTests": { "mode": "ci", "workflow": ".github/workflows/ci.yml", "check": "Vinaya CI" }
  }
}

securityScan

object (optional)

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

string[] (required within securityScan, min 1)

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

object (optional)

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

number (optional, non-negative integer)

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

number (optional, non-negative integer)

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

number (optional, non-negative integer)

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

number (optional, non-negative integer)

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

number (optional, non-negative integer)

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 } }

proseGates

object (optional)

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.

The writing rules themselves are one rule set run by two checks: reader-resolvable-prose runs its reader-resolvable group and retired-vocabulary runs its retired group. rules below configures that set; vinaya check --plan prints which rules each check runs.

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 (productPaths below, PRODUCT_SLUG_SCOPE by default), 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

string (optional, min 1)

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, and so do quoted-command and doc-claims, which sweeps the tracked pages under it for claim markers (a root outside the repository leaves that half of its sweep empty).

{ "doctrineRoot": "governance-docs" }

proseGates.readerFacingPrefix

string (optional, min 1)

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

string (optional, min 1)

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

string (optional, min 1)

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

object (optional)

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

string[] (optional)

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

string[] (optional)

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

"warning" | "error" (optional)

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

string[] (optional)

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

Record<string, integer ≥ 0> | string[] (optional)

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 } }

proseGates.productPaths

string[] (optional)

Repository-relative files or folders that make up reader-resolvable-prose's product class — the class where an internal tranche slug blocks outright, in comments and prose alike. Unset, the class reads this repository's own default set (PRODUCT_SLUG_SCOPE: the CLI source, the workflows folder, .vinaya, and two package READMEs).

Setting it replaces that default set; it never adds to it. A folder entry is swept for .ts, .tsx, .yml and .md files. An entry that leaves the repository — absolute, or climbing through .. — is refused when the configuration loads.

{ "productPaths": ["src", "README.md"] }

proseGates.published

object (optional)

The published pass — the documentation gate's check that a published page carries no token a stranger cannot resolve. allow is its one setting; its rules are not configured through proseGates.rules.

{ "published": { "allow": [".worktrees/task/<tranche>/<n>", "task/<tranche>/<n>"] } }

proseGates.published.allow

string[] (optional)

Literal text masked out of published prose before any rule reads it — the protocol mechanics a reader is meant to learn, such as the task branch convention. Masked longest first, so a shorter literal never cuts a longer one apart.

Unset, the shipped list applies: the task worktree path, the task branch name and the worktrees folder. Setting it replaces that list rather than adding to it, so a repository that wants the shipped entries too lists them.

{ "allow": [".worktrees/task/<tranche>/<n>", "task/<tranche>/<n>", ".worktrees/"] }

proseGates.rules

object (optional)

The writing rules both prose checks run, configured by the repository: exempt (paths no rule reads), defaults (switch a shipped rule off or retune it) and custom (rules the repository writes itself). Read at check-run time, so an edit takes effect on the next vinaya check with no reinstall.

Each rule belongs to exactly one check by its scopes. reader-resolvable-prose runs every rule whose scopes are ships (the doctrine tree), reader-facing (the configured reader-facing pages), product (productPaths), spec (the spec class) or source-comment (the source-comment class); retired-vocabulary runs every rule whose scope is repository (the doctrine tree, line by line). Replacing either check with a no-op turns off exactly its rules.

With none of it set, every finding is identical to before. The whole key is validated when the configuration loads, and every refusal names the key it refuses — a malformed rule refuses the run, never falls back to no rule.

{
  "proseGates": {
    "rules": {
      "exempt": ["docs/archive"],
      "defaults": {
        "coined-term": "off",
        "tranche-slug": { "scopes": ["spec", "product"], "severity": { "spec": "warning" } }
      },
      "custom": [
        { "id": "acme/leverage", "pattern": "leverage", "flags": "i", "means": "say 'use' instead", "scopes": ["ships", "spec"] }
      ]
    }
  }
}

proseGates.rules.exempt

string[] (optional)

Repository-relative files or folders no rule reads, in either check. A folder entry covers everything under it; a trailing slash or a leading ./ is accepted. An entry that leaves the repository is refused.

{ "exempt": ["docs/archive", "CHANGELOG.md"] }

proseGates.rules.defaults

Record<default rule id, "off" | { scopes?, severity?, exempt? }> (optional)

Per shipped rule, by its id (vinaya check --plan lists them): "off" stops the rule entirely; an object retunes it. scopes replaces where the rule reads — any scope of its own check for a pattern rule; a built-in rule (legacy-batch-name, coined-term) may only narrow its own scopes. severity is "error", "warning", or a map from scope to one of them, merged over the rule's shipped severities. exempt adds paths this one rule does not read.

A default's pattern and message are fixed: naming pattern, patterns, flags or means is refused — write a custom rule instead. An unknown id is refused, and so are the published pass's own rules, which this key does not configure.

proseGates.sourceComments.severity stays the default severity of the source-comment scope; a rule's own severity for that scope overrides it.

{ "defaults": { "forge-number": "off", "task-number": { "severity": "warning", "exempt": ["SPEC.md"] } } }

proseGates.rules.custom

CustomRule[] (optional)

Rules a repository writes for itself. Each takes id (its own — a default id is refused), pattern or a per-scope patterns map (or both: the map wins for the scopes it names), optional flags (only i and u), means (the message a finding prints), scopes (at least one, all belonging to one check), optional severity (as for a default) and optional exempt.

A finding prints the rule's id, the file and line, the matched text and means, e.g. SPEC.md:3: acme/leverage: say "use" ("Leverage"). With no severity, a rule warns in every scope except repository, where it blocks like the rest of retired-vocabulary, and source-comment, where it follows proseGates.sourceComments.severity.

Refused at load, naming the key: a pattern that does not compile; a rule with no means or no scope; an unknown scope, severity or flag; a patterns map naming a scope the rule does not cover, or leaving one of its scopes with no pattern; an exempt path outside the repository; a pattern longer than 200 characters; and a pattern whose star height is above one as the safe-regex2 library decides it. The last two limits hold only for configured patterns — two shipped defaults fail the star-height test and still run, so a repository that copies one into a custom rule is refused, and the message says so.

{
  "custom": [
    { "id": "acme/todo", "patterns": { "spec": "TODO" }, "means": "unfinished prose", "scopes": ["spec"], "severity": "error" }
  ]
}

decisionRecords

object (optional)

Opt-in decision records: one short file per decision, numbered in order, kept in a folder of this repository. Its one setting is path. Absent — the default — every check, command and finding behaves exactly as if the key did not exist.

{ "decisionRecords": { "path": "docs/adr" } }

decisionRecords.path

string (optional)

The folder, relative to the repository root, that holds the decision records. Refused at load, naming this key, when it leaves the repository (absolute, or climbing through ..), names the repository root itself, or sits inside the doctrine tree (proseGates.doctrineRoot, or aeg-root when that is unset).

Set, it does three things. vinaya new adr <title> writes the next numbered record into the folder and indexes it. The folder joins the writing rules' spec scope, each file read once even when the folder is also the default docs/adr. And the decision-record-citation rule runs: it blocks ADR followed by a number, and any path to a numbered file in the folder, in the doctrine pages, the specs, product code, source comments and the published pass. The folder itself is exempt, so a record may name the record it supersedes.

The rule exists only while this key is set, so proseGates.rules.defaults does not address it, and a custom rule may not take its id.

{ "path": "docs/adr" }

report

object (optional)

Policy for vinaya pr report's evidence runner (Group C, the Test Plan's [agent] fenced command list).

{ "report": { "commandTimeoutMs": 900000 } }

report.commandTimeoutMs

number (optional)

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 } }

What's next