@attalabs/vinaya 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,11 +16,11 @@ provenance: >
16
16
  they are not in the surface map of a task that only meant to add a gate:
17
17
 
18
18
  - packages/aeg-core/src/registry-parse.test.ts asserts the TOTAL row count
19
- across all three rings (currently 34).
19
+ across all three rings (currently 40).
20
20
  - packages/aeg-core/src/markdown-table.test.ts asserts per-ring counts
21
- (currently ring 0 = 12, ring 1 = 16, ring 2 = 6).
21
+ (currently ring 0 = 16, ring 1 = 17, ring 2 = 7).
22
22
  - packages/aeg-core/src/docs/node-route.test.ts asserts the ring-0 count
23
- (currently 12) alongside the actions/roles/contracts counts.
23
+ (currently 16) alongside the actions/roles/contracts counts.
24
24
 
25
25
  Bumping those numbers is a mechanical consequence of a correct edit, not a
26
26
  design change — fix them in the same commit and say so in the PR body.
@@ -79,6 +79,10 @@ The same check implementations run at ring 0 and ring 1 — one codebase, two en
79
79
  | Opening a task PR (final self-check before creation) | Ever opened a PR and only then discovered the tests were failing? | event | Runs the whole exit check before a pull request is created, so failures surface first. | **`verify-task` CLI** (`packages/aeg-core/bin/verify-task.ts` — mandated by `roles/developer.md`; **now also hook-automated** — task 25) | Typecheck, lint, tests, build, `verify-docs --pr`, and the premise coverage/recheck pair all pass, as one summary, against the PR's actual diff, before `open-pr.ts` is invoked. **`open-pr.ts` now runs this composite itself** for task branches (via `gatePlanForBranch`), invoked wholesale rather than partially re-implemented — non-task branches are unaffected (byte-identical gate set). `verify-docs --pr` runs twice on a task-branch PR-open as an accepted, measured overlap (~4s with a warm turbo cache). | `packages/aeg-core/bin/verify-task.ts` |
80
80
  | Spawning a check (`vinaya check`, ring-0 pre-push AND ring-1 CI) | Ever had a check quietly read a secret it had no business seeing, because nothing scoped what it could reach? | hook | Governs which environment variables a spawned check's child process can see, instead of every check inheriting the full parent environment unconditionally. | **Env allowlist** (`CheckSpec['env']`, `apps/cli/src/checks/contract.ts`; construction: `buildCheckEnv`, `apps/cli/src/checks/runner.ts`) | **⚠️ BREAKING, live (task 3):** a spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its own `env` declaration (`true` / `{ optional: true }` / `{ anyOf: [...] }` / a literal string) explicitly forwards — **no longer the full parent environment.** An undeclared variable a check's own code reads is now genuinely invisible to it, not merely warned about; a `true` or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` and the check never spawns at all. All 15 core checks in `registry.ts` carry an audited declaration, and every core check that spawns `gh` or reaches the forge through the token-resolution chain additionally forwards `GITHUB_TOKEN`/`GH_TOKEN` as `{ optional: true }` — on a CI runner those env vars are `gh`'s only authentication path, so a check that shells out without forwarding them runs unauthenticated there (hard-failing or silently degrading, depending on the bin's failure mode) while passing locally on `gh`'s keyring; the pairing is coupling-tested (`apps/cli/tests/checks/registry-env.test.ts` detects `gh` invocations in any call shape — array-form, string-form, and template-literal — and demands both declarations); that detection is source-text pattern matching, so it does not yet catch a check that reaches the forge indirectly through `createForgeSource` (`@atta/vinaya-sources`) with no literal `gh` call of its own — found live: `closes-n` reaches the forge this way and had shipped without either token declared, passing locally under a developer's own `gh` keyring and failing unauthenticated on every CI runner, a known gap in the coupling test's own coverage rather than in the rule it's checking. This repo's own core checks are therefore unaffected — an adopter's **custom** check that reads `process.env` directly with no `env` declared will lose that access at this minor; declare `env` on it (`vinaya.config.json`'s `checks.<name>.env`) before upgrading. `vinaya check`'s prior warn-phase print (task 2) is retired now that the behavior it warned about is live; `vinaya doctor` still carries the same missing-declaration diagnostic permanently, at `info` severity, so a custom check's gap remains visible after upgrade even though `vinaya check` no longer prints it inline. A second field, `requiresOpenPr?: boolean` (same no-privileged-field discipline as `env` — a custom check can declare it exactly like a core one), marks a check that can only evaluate meaningfully once a pull request exists; the generated `pre-commit`/`pre-push` hooks pass a new `--local` flag that skips every check declaring it, while CI (`vinaya-checks.yml`, `pull_request`-triggered) omits the flag and always runs them for real. Found live: `closes-n`/`test-plan` ran unconditionally in both hooks with no `requiresOpenPr` field to opt out on, so the first commit on a fresh task branch could never satisfy either — no PR exists yet at commit or push time, only after. Found live again, same missing-declaration class: `brief-shape` read `BRANCH` from `process.env` in its own bin without declaring it in `registry.ts`'s allowlist, so the runner stripped the variable before the child ever spawned — the non-task-branch bypass its logic depended on (skipping the `Closes #N` requirement for a standalone brief with no Issue to close) silently never fired; `registry.ts` now declares `BRANCH: { optional: true }` on that entry alongside `PR_BODY`, mirroring `test-plan`'s existing declaration for the same pair. `vinaya.config.json`'s `principals` field (the review-gate/waiver trust anchor, `apps/cli/src/lib/config.ts`'s `resolvePrincipalAllowlist`) is resolved via `loadTrustAnchorConfig()` — a `gh api` read of the repository's DEFAULT BRANCH, never local git, the PR's working tree, or any env var. Three successive attempts got this wrong before landing (all caught pre-merge): reading the PR's working tree; reading `git show ${BASE_SHA}:…` where `BASE_SHA` was an env var; and reading `git show origin/main:…`, where `origin/main` is a LOCAL remote-tracking ref the PR's own workflow can `git update-ref`. The rule they establish: **inside a `pull_request`-triggered workflow nothing on the job's own disk or environment is a trust boundary against the PR author**, because the workflow definition itself comes from the PR. `check-doc-coverage.ts`/`check-doc-coverage-push.ts` keep their own, separate, legitimately-overridable `BASE_SHA` for diff-scoping only, never reused for a trust decision. The API read raises the bar but is not itself the boundary — and the boundary is not where the earlier version of this sentence put it. **Branch protection with the check marked required is still worth enabling** — `init` prints that command and `doctor` reports its absence — but state exactly what it buys: a required status check is satisfied by a conclusion reported under its name, and it does not certify that the conclusion came from running the real check. Deleting the gate's *step* does not stop the *job* reporting green; only removing the job or the workflow outright produces the never-reports case that leaves a PR unmergeable, and a step edited to `exit 0` reports success under the required name having run nothing at all. So the rule stated in bold above extends one step further, to the verdict itself: **the trust boundary is who controls the workflow definition that produces the required check, and under a `pull_request` trigger that is the PR author — whatever the check is packaged as.** Packaging decides the blast radius, not the boundary. Where CI invokes an immutable published artifact, the invocation is the only thing the PR can rewrite. Where a repo vendors the CLI — its own workspaces declaring the published package name, which needs a build-and-run CI shape because `npx` matches on package **name** before any version spec is read and would otherwise exec an unbuilt local bin — the PR additionally controls the check sources, the build script, and the **dependency lifecycle scripts** the install step executes in a base-branch-scoped cache: the surface becomes all code the PR controls. The same widening arrives with no packaging question at all whenever a required workflow checks out the PR's head and runs a gate from that tree — this harness's own review gate does exactly that, so it sits in the wider class today by a hand-written workflow, not by any generator. Two things are therefore NOT claimed here. Not that verdict tampering is closed for an ordinary adopter: it is not, and an adopter who enables branch protection and stops there has closed merge-without-a-report and nothing else. And not that the vendored CI shape is something generated today — as of 2026-08-14 `init` writes only the published `npx` invocation, which in a vendoring repo misresolves to the unbuilt local bin and kills the job, so such a repo's required check currently yields no verdict at all rather than an untrustworthy one; that is its own governance problem, and the build-and-run generator that fixes it is not yet merged. What closes the residual gap is not a mechanism: a repo whose required checks are built from code its own pull requests can edit is governing itself, and there the last line is the reviewer — changes to check sources, the build script, or the dependency manifest are reviewed as governance changes, not as ordinary code. | `apps/cli/src/checks/runner.ts` |
81
81
  | `vinaya check` / `vinaya check --plan` (a CLI mechanism, not a git hook — this row names no hook path) | Ever had a config entry silently double-run alongside the core check it was meant to replace, with no way to see that from the outside? | event | Resolves core-registered and config-registered checks into one deterministic table before anything runs, instead of letting a config entry run alongside the core check it collides with, unannounced. | **Checks-side resolver** (`resolveChecks`/`isValidNamespacedKey`, `apps/cli/src/checks/resolver.ts`) | An exact-key match against a core check ID is an **override** — a complete replacement of the spec, never a merge. A key containing exactly one `/`, both segments matching `[a-z0-9][a-z0-9-]*` and the `vinaya` prefix rejected as an exact segment match, is **additive**. Anything else — a bare key with no `/` matching no core ID — is a loud `FAIL_CLOSED` refusal: never silently dropped, never defaulted, never last-wins. `--plan` never resolves or prints an env *value*, only how each one resolves. **This release feeds `vinaya check --plan` / `--plan --json` only** — `vinaya check`'s real execution (`check.ts`'s flat `[...coreCheckRegistry(), ...customSpecs]` concat) is unaffected until a later task wires it in. | `apps/cli/src/checks/resolver.ts` |
82
+ | Pushing a task branch that matches no planned task | Ever pushed a task branch that belonged to no plan? | hook | Refuses to push a task branch whose name matches no task row derived from the forge. | **`check-branch-topology` standalone shim** (in this repo the managed pre-push hook runs the same gate as the registered `branch-topology` check — a thin adapter in `apps/cli/src/checks/bin/` over the same pure evaluator; this row names the seeded standalone CLI form) | The branch's `task/<tranche>/<n>` fields resolve against the forge-derived tranche (a Milestone plus labeled task Issues): the tranche derives, and some task row's id literal-equals `<n>`. Fail-closed — forge/network unreachability refuses the push, matching `verify-dispatch`'s precedent for hard gates, unlike the fail-open dead-branch guard below. Known prose staleness, accepted: its refusal text still names a per-tranche topology file under `aeg-root/tranches/` that post-cutover repos no longer carry — the gate's logic is forge-native and correct, but the message text is a byte-identical-compatibility contract shared with the registered adapter and asserted verbatim by tests, so rewording it is a real change to that contract, not a message tweak. | `packages/aeg-core/bin/check-branch-topology.ts` |
83
+ | Pushing to a branch whose pull request already resolved | Ever pushed more commits to an already-merged branch? | hook | Refuses a push to a task branch whose most recent pull request is already merged or closed. | **`check-push-target` standalone shim** (the registered `dead-branch-push` check is a thin adapter over the same `checkDeadBranchPush` evaluator; this row names the seeded standalone CLI form) | The branch's most recent pull request — one batched `gh pr list --head <branch>` call — is still `OPEN`, or none exists yet. Deliberately fail-open: any forge-reachability failure (auth, network, rate limit, malformed JSON) maps to `UNKNOWN`, treated as allow — a transient outage must never block every push. The ring-2 dead-branch-push audit below is this gate's detection backstop for writers the hook cannot reach. | `packages/aeg-core/bin/check-push-target.ts` |
84
+ | A task branch's first push (dispatch re-check) | Ever found out mid-push that your task was never dispatchable? | hook | Re-runs the dispatch-readiness gate once, on a task branch's first push, before its pull request exists. | **`check-first-push-dispatch` standalone shim** (wraps the unchanged `verify-dispatch` gate mode — see the dedicated `verify-dispatch` row above; the registered `first-push-dispatch` check classifies the same readiness through the shared evaluator) | On a `task/*/*` branch with no pull request yet, `verify-dispatch`'s `dispatch-readiness:` line reads `READY`. Only that line is read — never the combined exit code, which also folds in leftover-detection and would false-block every push after the first on a task legitimately mid-flight. Fail-open on infrastructure, loudly: a failed `gh auth status` probe (or `verify-dispatch`'s own infra marker) classifies as `UNKNOWN` and the push is allowed; unparseable output refuses — fail loud, never silently allow. Once a PR exists, later pushes skip the gate: dispatch was already validated once. | `packages/aeg-core/bin/check-first-push-dispatch.ts` |
85
+ | A task branch's first push (Issue self-assignment) | Ever had no idea who was actually working a task? | hook | Assigns the task's Issue to the authenticated pusher on the branch's genuinely first push — visibility automation, deliberately not a gate. | **`assign-task-issue` standalone shim** (the registered `issue-assignment` check mirrors it; the Issue-self-assignment note below this table is the same mechanism's prose record) | Nothing — this row names a mechanism, not a refusal. Fail-open by contract: it runs after every blocking gate has passed, degrades every failed forge call to a skip, warns and exits 0 on any API failure, and nothing gates on its exit code — a failed assignment can never block a legitimate push. Idempotent: an already-assigned Issue is a no-op, and a push to an already-existing branch never re-triggers it. | `packages/aeg-core/bin/assign-task-issue.ts` |
82
86
 
83
87
  **Documentation coverage, specifically** (a historical pain point): the code→document ownership rule is enforced at *two* prevention chokepoints — at every push (over the branch's cumulative change set) and again at pull-request creation and editing. A change to owned code cannot be published, let alone turned into a pull request, without its owning document.
84
88
 
@@ -114,6 +118,7 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
114
118
  | G4 — cited forge numbers resolve | Ever read a doc that cited a ticket number that didn't exist? | ci | Re-checks that every Issue and PR number cited in the docs resolves to a real one. | Every `#NNN` cited anywhere in this page's body is a real Issue or PR in the forge — a fabricated citation fails the build. **Currently scans nothing by design** (task 14): this page's body carries zero forge citations, since task 3 banned them from `aeg-root/**` as doctrine and the `reader-resolvable-prose` check (task 15) now enforces that ban directly. G4 is a standing guard against reintroduction, not a live proof of ongoing citation correctness — it fires the moment a `#NNN` reappears here and doesn't resolve, but has nothing to scan while the ban holds. Verified live: `packages/aeg-core/src/registry-checks.test.ts` appends a fabricated citation to this page's real content and asserts `checkG4` catches it. | `packages/aeg-core/bin/verify-registry.ts` |
115
119
  | G5 — role/contract integrity | Ever had a process doc reference a role that was never actually defined? | ci | Re-checks that every role and contract the doctrine references is really defined. | Every `aeg-root/contracts/*.md`'s `producer`/`consumer` names a real `role_id` from `aeg-root/roles/*.md`; every role's `performs`/`refuses_when` frontmatter is present and non-empty. | `packages/aeg-core/bin/verify-registry.ts` |
116
120
  | reader-resolvable-prose | Ever read a doc that assumed you already worked here? | ci | Re-checks that reader-facing doctrine and site pages don't cite a forge number/tranche slug the reader can't resolve, or use coined vocabulary without defining it. | Two mechanizable classes over every `aeg-root/**` doc and every public-site `(site)/**/page.tsx`, never `apps/*/specs/**` or a `CLAUDE.md` (this reader has this forge): unresolvable references (a bare forge number, or a `-vN`/legacy tranche slug — the same pattern shapes `retired-vocabulary.test.ts` proved, scoped wider here); and a coined term (`tranche`, `brief`, `forge`, `provenance`, `dispatch`, …) used with neither an inline definition nor a link to the glossary — the term list is derived live from `aeg-root/glossary.md`'s own entry headings, never hard-coded. NOT registered in `coreCheckRegistry()` — its scan hardcodes the attalabs monorepo's own doctrine layout and site paths, a scope-registration decision recorded in `apps/cli/src/checks/registry.ts` — so in this repo it runs only on direct invocation (`vinaya check reader-resolvable-prose`); it is part of neither the managed hooks nor `vinaya-checks.yml`. | `apps/cli/src/checks/bin/check-reader-resolvable-prose.ts` |
121
+ | No new on-disk state | Ever had a file and the forge disagree about the same fact? | ci | Blocks a diff that creates a new on-disk state file duplicating what the forge already derives. | No live per-tranche topology file appears under `aeg-root/tranches/` (a top-level file there fails on add or edit; under `completed/**` only genuinely NEW files are refused, so the legacy archives stay editable), and no new `*.tokens.md` appears anywhere — token figures live in the PR body, and the ledger row is appended post-merge. Status-aware (`git diff --name-status`), never fired by a deletion; a rename counts as an addition at its new path. The registered `no-disk-state` check runs the same `isNewDiskStateFile` predicate at ring 0 (the managed hooks) and here; this row names the standalone CLI shim of that gate. | `packages/aeg-core/bin/check-no-disk-state.ts` |
117
122
 
118
123
  **G-checks rollout (task 3):** G1/G2 ship report-only this tranche — they can only ever print an `info` finding, never fail CI, so pre-existing gaps (four orphan hooks, several orphan `bin/*.ts` CLIs found on first run) surface as visible debt without retroactively failing in-flight work. G3/G4/G5 are blocking from this tranche onward. G1 flips to blocking in a later, separately-dispatched task, once the report-only period has let the orphan backlog get cleaned up.
119
124
 
@@ -133,6 +138,7 @@ Red CI is now unmergeable by any actor — the repository ruleset's `required_st
133
138
  | **Staleness audits** | Ever had documentation flatly contradict a decision everyone agreed to? | event | Flags documentation that has fallen behind the decisions it is meant to follow. | Dispatched periodically | Documentation whose claims contradict recorded decisions; each contradiction becomes a tracked fix Issue | |
134
139
  | Direct-main-push detection | Ever had someone push straight to main and nobody noticed for a while? | event | Catches pushes that reached main anyway, including from writers the hooks cannot reach. | Every push to `main`, or manual `workflow_dispatch` (task 31) | Whether the pushed commit is introduced by a merged pull request, via the commits→pulls association API. No associated merged PR → opens an incident Issue (`aeg:direct-main-push` label, idempotent per SHA) and **fails the run loudly** — the one ring-2 check in this table that IS allowed to go red, because nothing it does blocks a pull request or a merge; it only makes an already-irreversible direct push visible. **Detection only, never mutation** — no revert, no force-push; permissions are read-only plus `issues: write`, including the `pull-requests: read` the association API requires — as shipped by task 24 that scope was missing, so the job 403'd (`Resource not accessible by integration`) on every real `main` push instead of detecting anything; fixed by task 31 (item 2). | `packages/aeg-core/bin/check-direct-main-push.ts` |
135
140
  | Dead-branch-push audit | Ever kept committing to a branch after its PR had already merged? | event | Catches commits still landing on a branch whose pull request already resolved. | Scheduled (`0 2 * * *`) or manual `workflow_dispatch` | Every remote `task/*` branch whose most recent PR already resolved (`MERGED`/`CLOSED`): flags it when the branch's current tip commit is dated strictly after that resolution — commits kept landing on a branch after the forge considered its work done (the exact "six topology rows landed on a merged plan PR's branch" incident class, 2026-07-03, now caught within a day instead of never). `aeg:dead-branch-push` label plus one idempotent tracking comment on the branch's own PR. **Notification only, never a gate** — `continue-on-error: true`. (2026-07-13: this job previously also ran a stuck row-adjacent-blocker check, `stale-blocker.ts`, retired along with the dispatch-gate predicate it watched for.) | `packages/aeg-core/bin/dead-branch-audit.ts` |
141
+ | Token self-report | Ever seen a cost figure nobody could trace to a source? | event | Reads a role's exact token usage from its own session transcript, feeding the PR-body token report the post-merge ledger row is built from. | By each terminal role at turn-end, before its pull request opens or re-pushes — mandated by the role docs, not hook-fired | Reporting, not enforcement — it refuses no repo action; its refusals guard only its own output. A stale transcript pointer (written by a previous session in a reused worktree) or a transcript yielding zero usage data throws loudly instead of emitting a plausible-looking wrong or zero figure — the misattribution and fabrication classes that would otherwise flow into the PR body and, from there, the post-merge token ledger. | `packages/aeg-core/bin/report-tokens.ts` |
136
142
 
137
143
  ---
138
144
 
@@ -3028,7 +3028,8 @@ var VinayaConfigSchema = z.object({
3028
3028
  checks: z.record(z.string(), CheckEntrySchema).optional(),
3029
3029
  briefSchema: BriefSchemaSchema.optional(),
3030
3030
  managed: ManagedManifestSchema.optional(),
3031
- principals: z.array(z.string()).min(1).optional()
3031
+ principals: z.array(z.string()).min(1).optional(),
3032
+ ci: z.object({ setup: z.string().min(1) }).optional()
3032
3033
  });
3033
3034
  var GLOBAL_VINAYA_HOME = join(homedir(), ".vinaya");
3034
3035
  var GLOBAL_CONFIG_PATH = join(GLOBAL_VINAYA_HOME, "config.json");
@@ -3029,7 +3029,8 @@ var VinayaConfigSchema = z.object({
3029
3029
  checks: z.record(z.string(), CheckEntrySchema).optional(),
3030
3030
  briefSchema: BriefSchemaSchema.optional(),
3031
3031
  managed: ManagedManifestSchema.optional(),
3032
- principals: z.array(z.string()).min(1).optional()
3032
+ principals: z.array(z.string()).min(1).optional(),
3033
+ ci: z.object({ setup: z.string().min(1) }).optional()
3033
3034
  });
3034
3035
  var GLOBAL_VINAYA_HOME = join(homedir(), ".vinaya");
3035
3036
  var GLOBAL_CONFIG_PATH = join(GLOBAL_VINAYA_HOME, "config.json");
@@ -3027,7 +3027,8 @@ var VinayaConfigSchema = z.object({
3027
3027
  checks: z.record(z.string(), CheckEntrySchema).optional(),
3028
3028
  briefSchema: BriefSchemaSchema.optional(),
3029
3029
  managed: ManagedManifestSchema.optional(),
3030
- principals: z.array(z.string()).min(1).optional()
3030
+ principals: z.array(z.string()).min(1).optional(),
3031
+ ci: z.object({ setup: z.string().min(1) }).optional()
3031
3032
  });
3032
3033
  var GLOBAL_VINAYA_HOME = join(homedir(), ".vinaya");
3033
3034
  var GLOBAL_CONFIG_PATH = join(GLOBAL_VINAYA_HOME, "config.json");
package/dist/index.js CHANGED
@@ -950,7 +950,25 @@ function vinayaSetupSteps(selfHost) {
950
950
  function vinayaRun(selfHost, args) {
951
951
  return selfHost ? `node ${selfHost.bin} ${args}` : `npx --yes @attalabs/vinaya ${args}`;
952
952
  }
953
- function checksWorkflow(selfHost) {
953
+ function adopterSetupStep(ciSetup) {
954
+ if (!ciSetup)
955
+ return "";
956
+ const indented = ciSetup.trimStart().split(`
957
+ `).map((line) => line.length > 0 ? ` ${line}` : line).join(`
958
+ `);
959
+ return ` # Adopter-declared CI setup (vinaya.config.json \`ci.setup\`), emitted
960
+ # verbatim. It prepares this repository's OWN custom checks — scripts
961
+ # committed here that may import this repository's code — which the
962
+ # \`npx\` invocation below cannot do: npx prepares only vinaya itself.
963
+ # The command is repo-committed, reviewed config; under \`pull_request\`
964
+ # the workflow definition is already controlled by the pull request, so
965
+ # this step adds no trust surface that did not already exist.
966
+ - name: Adopter CI setup
967
+ run: |
968
+ ${indented}
969
+ `;
970
+ }
971
+ function checksWorkflow(selfHost, ciSetup) {
954
972
  return `# ${MANAGED_NOTE}
955
973
  #
956
974
  # The deterministic gate suite. Runs every registered vinaya check over the
@@ -1006,7 +1024,7 @@ jobs:
1006
1024
  - uses: actions/setup-node@v4
1007
1025
  with:
1008
1026
  node-version: 20
1009
- ${vinayaSetupSteps(selfHost)} - name: Run checks
1027
+ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Run checks
1010
1028
  env:
1011
1029
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1012
1030
  PR_NUMBER: \${{ github.event.pull_request.number }}
@@ -1040,7 +1058,7 @@ ${vinayaSetupSteps(selfHost)} - name: Run checks
1040
1058
  } >> "$GITHUB_STEP_SUMMARY"
1041
1059
  `;
1042
1060
  }
1043
- function reviewWorkflow(selfHost) {
1061
+ function reviewWorkflow(selfHost, ciSetup) {
1044
1062
  return `# ${MANAGED_NOTE}
1045
1063
  #
1046
1064
  # The required review gate — pull_request events only. The verdict-comment
@@ -1101,7 +1119,7 @@ jobs:
1101
1119
  - uses: actions/setup-node@v4
1102
1120
  with:
1103
1121
  node-version: 20
1104
- ${vinayaSetupSteps(selfHost)} - name: Review gate
1122
+ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review gate
1105
1123
  env:
1106
1124
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1107
1125
  # PR_NUMBER is what makes the review-gate check EVALUATE: without
@@ -1112,7 +1130,7 @@ ${vinayaSetupSteps(selfHost)} - name: Review gate
1112
1130
  run: ${vinayaRun(selfHost, "check review-gate")}
1113
1131
  `;
1114
1132
  }
1115
- function reviewVerdictWorkflow(selfHost) {
1133
+ function reviewVerdictWorkflow(selfHost, ciSetup) {
1116
1134
  return `# ${MANAGED_NOTE}
1117
1135
  #
1118
1136
  # The verdict-comment half of the review gate. A reviewer's verdict arrives
@@ -1171,7 +1189,7 @@ jobs:
1171
1189
  - uses: actions/setup-node@v4
1172
1190
  with:
1173
1191
  node-version: 20
1174
- ${vinayaSetupSteps(selfHost)} - name: Review gate (verdict evaluation)
1192
+ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review gate (verdict evaluation)
1175
1193
  env:
1176
1194
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1177
1195
  # Same wiring as the required workflow: PR_NUMBER is what makes
@@ -1496,19 +1514,19 @@ function buildInitOps(ctx) {
1496
1514
  ops.push({
1497
1515
  kind: "create-file",
1498
1516
  path: CHECKS_WORKFLOW_PATH,
1499
- content: checksWorkflow(ctx.selfHost),
1517
+ content: checksWorkflow(ctx.selfHost, ctx.ciSetup),
1500
1518
  group: "CI workflows"
1501
1519
  });
1502
1520
  ops.push({
1503
1521
  kind: "create-file",
1504
1522
  path: REVIEW_WORKFLOW_PATH,
1505
- content: reviewWorkflow(ctx.selfHost),
1523
+ content: reviewWorkflow(ctx.selfHost, ctx.ciSetup),
1506
1524
  group: "CI workflows"
1507
1525
  });
1508
1526
  ops.push({
1509
1527
  kind: "create-file",
1510
1528
  path: REVIEW_VERDICT_WORKFLOW_PATH,
1511
- content: reviewVerdictWorkflow(ctx.selfHost),
1529
+ content: reviewVerdictWorkflow(ctx.selfHost, ctx.ciSetup),
1512
1530
  group: "CI workflows"
1513
1531
  });
1514
1532
  ops.push({
@@ -1714,7 +1732,8 @@ var VinayaConfigSchema = z.object({
1714
1732
  checks: z.record(z.string(), CheckEntrySchema).optional(),
1715
1733
  briefSchema: BriefSchemaSchema.optional(),
1716
1734
  managed: ManagedManifestSchema.optional(),
1717
- principals: z.array(z.string()).min(1).optional()
1735
+ principals: z.array(z.string()).min(1).optional(),
1736
+ ci: z.object({ setup: z.string().min(1) }).optional()
1718
1737
  });
1719
1738
  var GLOBAL_VINAYA_HOME = join3(homedir(), ".vinaya");
1720
1739
  var GLOBAL_CONFIG_PATH = join3(GLOBAL_VINAYA_HOME, "config.json");
@@ -1759,6 +1778,16 @@ function stripGlobalOnlyKeys(config, path) {
1759
1778
  }
1760
1779
  return result;
1761
1780
  }
1781
+ function readRepoCiSetup(repoRoot) {
1782
+ const p = join3(repoRoot, "vinaya.config.json");
1783
+ if (!existsSync2(p))
1784
+ return null;
1785
+ try {
1786
+ return VinayaConfigSchema.parse(JSON.parse(readFileSync2(p, "utf-8"))).ci?.setup ?? null;
1787
+ } catch {
1788
+ return null;
1789
+ }
1790
+ }
1762
1791
  function loadConfigChecked() {
1763
1792
  const path = configPath();
1764
1793
  if (!path)
@@ -2862,6 +2891,9 @@ var BIN_EXT = BIN_DIR === DIST_BIN_DIR ? ".js" : ".ts";
2862
2891
  function bin(name) {
2863
2892
  return join6(BIN_DIR, `${name}${BIN_EXT}`);
2864
2893
  }
2894
+ function runsUnderAll(spec) {
2895
+ return !spec.ownWorkflow;
2896
+ }
2865
2897
  function coreCheckRegistry() {
2866
2898
  return [
2867
2899
  {
@@ -3501,7 +3533,7 @@ async function checkCommand(args) {
3501
3533
  }
3502
3534
  const { specs: customSpecs, errorOutcome, checks: configChecks } = customSpecsFromConfig();
3503
3535
  const allSpecs = [...coreCheckRegistry(), ...customSpecs];
3504
- const specsToRun = allRequested ? allSpecs.filter((s) => !s.ownWorkflow) : allSpecs.filter((s) => s.name === requestedName);
3536
+ const specsToRun = allRequested ? allSpecs.filter(runsUnderAll) : allSpecs.filter((s) => s.name === requestedName);
3505
3537
  if (!allRequested && specsToRun.length === 0) {
3506
3538
  console.error(`Unknown check: ${requestedName}`);
3507
3539
  process.exit(2);
@@ -4146,7 +4178,8 @@ async function runDoctor(args, deps) {
4146
4178
  owner: repo.owner,
4147
4179
  repo: repo.repo,
4148
4180
  hookDir,
4149
- selfHost: detectVendoredVinaya(repo.repoRoot)
4181
+ selfHost: detectVendoredVinaya(repo.repoRoot),
4182
+ ciSetup: readRepoCiSetup(repo.repoRoot)
4150
4183
  };
4151
4184
  const install = diagnoseInstall(repo.repoRoot, ctx, manifest);
4152
4185
  findings.push(...install.findings);
@@ -4478,7 +4511,8 @@ async function runInit(args, deps) {
4478
4511
  owner: repo.owner,
4479
4512
  repo: repo.repo,
4480
4513
  hookDir: deps.hookDirFor(repo.repoRoot),
4481
- selfHost: detectVendoredVinaya(repo.repoRoot)
4514
+ selfHost: detectVendoredVinaya(repo.repoRoot),
4515
+ ciSetup: readRepoCiSetup(repo.repoRoot)
4482
4516
  };
4483
4517
  const allOps = buildInitOps(ctx);
4484
4518
  const ops = noRemote ? allOps.filter((op) => op.kind !== "create-label") : allOps;
@@ -5742,7 +5776,8 @@ async function runUpgrade(args, deps) {
5742
5776
  owner: repo.owner,
5743
5777
  repo: repo.repo,
5744
5778
  hookDir: routing.target,
5745
- selfHost: detectVendoredVinaya(repo.repoRoot)
5779
+ selfHost: detectVendoredVinaya(repo.repoRoot),
5780
+ ciSetup: readRepoCiSetup(repo.repoRoot)
5746
5781
  };
5747
5782
  const ops = buildInitOps(ctx);
5748
5783
  const plan = planUpgrade(ops, repo.repoRoot, planManifest, routing);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@attalabs/vinaya",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Vinaya — Agentic Engineering Harness. Deterministic checks every AI coding agent must satisfy before merge.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",