Kairokuplaybook
Orchestration

The repo's own rules

Code shapes a repository refuses — blocked at the write, failed in QA.

A repository can state the code shapes it refuses, and every dispatched run is held to them. The statement lives in the repository, in .kairoku/rules/*.yml, in ast-grep's own rule format — one file per rule, or several rules in one file separated by ---.

Nothing in kairoku.json asks for this. The presence of the directory is what turns it on, so a repository cannot opt its own gate out in the same file the gate reads. No .kairoku/rules means nothing runs — the daemon proceeds exactly as it would for a repo that never adopted rules at all.

Read from the base branch, never the worktree

The daemon loads the rules with git ls-tree and git show against origin/<default-branch> — the same place it reads the manifest, for the same reason and one more:

A rule an agent can delete inside its own pull request is not a rule. A change to .kairoku/rules/ takes effect after the merge, never before it. An implementer that deletes a rule in its own worktree is still blocked by it.

They are materialised once per dispatch into that dispatch's own scratch directory, with a generated sgconfig.yml beside them — ast-grep's -r flag takes exactly one rule file, and a directory of them needs a project config. Once per dispatch rather than once per member, so every member of a team is held to one copy even if someone pushes to the base branch mid-fan-out.

What a rule looks like

This is the CLI repository's own rule, which it dogfoods against its own runs:

id: bun-spawn-resolved-path
language: TypeScript
severity: error
message: Bun.spawn must run the path Bun.which resolved, not the bare command name.
note: >-
  owds-inc/kairoku PR #7 defect 5 — `Bun.which(bin)` resolved a path and the later
  `Bun.spawn(["gh", …])` re-resolved the bare name itself, so a PATH change between
  the two (a service manager setting the daemon's PATH) ran a different binary than
  the one that was checked. Pass the resolved value.
rule:
  all:
    - pattern: Bun.spawn([$CMD, $$$ARGS], $$$OPTS)
    - inside:
        stopBy: end
        has:
          stopBy: end
          pattern: Bun.which($CMD)

Two fields carry the whole conversation with the agent. message is what it must do instead. note is the defect the rule exists for — the pull request, the class, and what actually went wrong — and it is handed to the agent verbatim, because a rule that says only "no" gets argued with and a rule that shows its scar gets obeyed.

Each rule is self-contained. The daemon materialises the files into a flat directory, so a rule that reached for utils: in a second file would lose it.

Two layers

WhereWhat happens
OneAt the writeThe agent is stopped and handed the rule's message and its note, while the fix is still one edit away
TwoIn QA, before the manifest's check commandsThe step fails with the rule ids and file:line, and the fix loop's next prompt carries that text

Layer one is the fast signal. On Claude it is a PostToolUse hook matched to Write|Edit, so ast-grep runs on the real file after the write — no temp-file reconstruction of what the agent was about to save. "Block" means the agent is stopped, not the disk: the SDK feeds the reason back and the turn continues, which is the point. Codex gets the same layer from a .codex/hooks.json the daemon writes into the worktree per run, whose PostToolUse command runs the same materialised scan and answers with Codex's blocking contract for a synchronous hook — exit 2, reason on stderr. Both files are written fresh per run and added to the checkout's info/exclude, so they are never part of the diff the agent commits.

Layer two is the gate of record, and it is the one that matters, because no host's hook layer is proven headless. Whenever rules were materialised, QA scans the whole worktree before the repository's own check commands run — a violation is a defect the fix loop can act on in seconds, and making the agent wait out a full suite to hear it is the same information an hour later.

Three things pass silently at layer one and are caught at layer two instead: a file outside the worktree (not this run's to judge), a file no rule's language parses, and a scan the daemon cannot read. That last one is deliberate — a hook that blocked every write because the scanner broke would burn the run's whole budget on a machine fault. QA fails closed on exactly that condition: output it cannot parse is a failure, never "no matches". [] from ast-grep is proof the scan ran; empty output from a broken scanner is proof of nothing.

A rule ships against a defect, and cites it

A rule exists only if it would have caught a defect a verifier actually found in a merged pull request. Not a style preference, not a lint someone likes. The note names the pull request.

That is why there are three defects behind them and not thirty — four rule ids, because one defect spans .ts and .tsx and a rule binds one language. Each is strict by construction, so a block is never a false positive an agent has to argue its way past:

RuleRepositoryThe defect it cites
no-self-delegating-mock, and its twin no-self-delegating-mock-tsx for .tsx filesthe appA mock.module factory that reads the namespace it is replacing. mock.module mutates the namespace object in place and patches the property, so a fall-through that reads the export back off it calls itself — the run wedges with no output and no counts. Eleven files across two pull requests
compose-ports-loopbackthe appA ports: entry in a compose file not prefixed 127.0.0.1:, which published a cheap-password Postgres and an unauthenticated /sql proxy to the whole LAN for the lifetime of every run
bun-spawn-resolved-paththe CLIBun.spawn re-resolving a bare command name that Bun.which had already resolved, so a PATH change between the two ran a different binary than the one that was checked

Strict by construction is doing real work in the first one: it fires only when the factory spreads a namespace and reads a member off that same namespace and that namespace is the live object of the specifier being mocked. Drop the third condition and the rule flags the fix as well as the bug.

Each of the first two replaced a hand-rolled scan in the app's own test suite. The scans caught the defect after the fact, in a suite run; the rule catches it in the agent's hands.

A machine without ast-grep fails the run closed

ast-grep is one binary. kairoku setup --daemon installs it, and kairoku doctor reports it beside the rule count for your base branch.

A repository whose base branch declares rules, on a machine with no ast-grep, fails the run at environment settlement — before a worktree is cut, naming ast-grep, exactly as a secret reference nobody on the machine can resolve does:

this repo's base branch declares 2 rule(s) in .kairoku/rules and ast-grep is not
installed on this machine, so they cannot be checked — `kairoku setup --daemon`
installs it

Failing closed rather than skipping is the only honest answer: a run that reported clean without having looked is worse than a run that did not start. This is the opposite of CodeGraph's probation, which degrades silently — a rule nobody checked is a false clean report; an index nobody built is only a slower agent.

Prove a rule against its own defect

A rule is code, and it rots the same way. ast-grep test runs a rule against fixtures — valid cases it must not match, invalid cases it must. Point sgconfig.yml at both directories:

ruleDirs:
  - .kairoku/rules
testConfigs:
  - testDir: .kairoku/rule-tests

Then .kairoku/rule-tests/<rule-id>-test.yml carries the cases. The invalid cases are the historical defects verbatim; the valid ones are the surrounding correct code — so the file proves both that the rule still catches what it was written for and that it matches nothing the repository already does:

id: compose-ports-loopback
valid:
  # compose.test.yml as it stands: both published ports on loopback.
  - |
    services:
      postgres:
        ports: !override
          - '127.0.0.1:${PG_PORT:-5433}:5432'
invalid:
  # app #389, exactly: the per-run port published on every interface.
  - |
    services:
      postgres:
        ports: !override
          - '${PG_PORT:-5433}:5432'
ast-grep test                                     # every rule
ast-grep test --filter '^compose-ports-loopback$' # one of them

The app wraps that command in its own suite, one test per rule, so a rule that stops matching its own defect fails CI rather than quietly passing everything. Those tests skip with a printed reason on a machine without ast-grep — nothing else in that suite needs the binary, and making a whole suite depend on a tool the daemon provisions would be the worse trade.

Writing one

Start from a defect a review actually found. Read the diff that fixed it. If you cannot name the pull request, there is no rule to write yet.

Add the fixtures first — the defect as an invalid case, and the nearest correct code in the repository as a valid one. Run ast-grep test and watch it fail.

Write the rule until both pass. ast-grep binds one language per rule, and maps files to languages by extension — a TypeScript rule never sees a .tsx file. A defect that spanned both means the same rule twice, under two ids.

Merge it. The rule is live on the next run of that repository, because runs read the base branch — and not before.

On this page