Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# IDE
.idea/
*.iml
.vscode/

# macOS
.DS_Store

# Editor scratch
*.swp
*~
79 changes: 72 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,74 @@
# Code generation rules
# code-generation-rules

Shared generation conventions for Backoffice contracts.
Shared engineering rules and agent tooling for the organization, mounted into
projects as a git submodule.

- Generated sources are never edited manually.
- Protobuf field numbers are immutable after publication.
- Removed protobuf fields and names are reserved.
- OpenAPI changes are validated and generated clients are rebuilt in the same pull request.
- Generation must be deterministic and runnable in CI without repository-local state.
The repository carries three things:

- `rules/` — the rules themselves, as plain markdown. Single source of truth.
- `hooks/` — scripts wired into agent lifecycle events (Claude Code and Codex).
- `install.sh` / `check.sh` — wire the above into a consuming project, idempotently.

## What belongs here

Only rules that hold for the whole organization. Anything tied to one service —
its packages, its build quirks, its local conventions — stays in that service's
own `AGENTS.md` / `CLAUDE.md`, outside the synced block.

## Adding to a project

```bash
git submodule add <repo-url> .agent-rules
./.agent-rules/install.sh
```

`install.sh` is idempotent and touches only what it owns:

- registers the Kotlin format hook in `.claude/settings.json` and `.codex/hooks.json`
- writes `@`-imports of `rules/*` into `CLAUDE.md`
- syncs the rule text into `AGENTS.md` between `<!-- BEGIN agent-rules -->` and
`<!-- END agent-rules -->`

Everything outside those markers is yours and is never rewritten.

Commit the resulting changes together with the submodule pointer.

## Updating

```bash
git submodule update --remote .agent-rules
./.agent-rules/install.sh
```

Review the diff, then commit. The bump is explicit per project — rules never
change under a project without a commit in it.

## Keeping projects honest

`check.sh` is `install.sh --check`: it writes nothing and exits non-zero when a
project has drifted from the submodule it pins. Wire it into CI with
`ci/github-actions/agent-rules-drift.yml` — note the `submodules: true` on
checkout, without it the check runs against an empty directory.

## The Kotlin format hook

`hooks/format-kotlin.sh` runs on the agent's `Stop` event — once per turn, after
the code is generated, in both Claude Code and Codex.

When the turn touched Kotlin, it runs `ktlint:format` and `ktlint:check` in one
maven invocation. Both goals are needed: `format` fixes what it can but exits
successfully while staying silent about the rest, so only `check` surfaces the
violations that need a human-shaped fix. Those are handed back to the agent,
which then has to correct them before the turn can end.

It is deliberately quiet and cheap: with no changed `.kt`/`.kts` files, or in a
project with no ktlint, it exits in well under a tenth of a second without
starting a JVM.

Note that `ktlint:format` covers the whole module, not just the changed files.
In a project where CI already enforces `ktlint:check`, everything committed is
formatted anyway, so this is a no-op on untouched code.

If a project needs specific environment to run its build (a particular
`JAVA_HOME`, a locale), put it in `.agent-rules.env` in the project root — the
hook sources it when present. That file belongs to the project, not here.
26 changes: 26 additions & 0 deletions agents/claude/settings.hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "if [ -x .agent-rules/hooks/format-kotlin.sh ]; then exec .agent-rules/hooks/format-kotlin.sh; fi",
"timeout": 300
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "if [ -x .agent-rules/hooks/format-kotlin.sh ]; then exec .agent-rules/hooks/format-kotlin.sh; fi",
"timeout": 300
}
]
}
]
}
}
26 changes: 26 additions & 0 deletions agents/codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "if [ -x .agent-rules/hooks/format-kotlin.sh ]; then exec .agent-rules/hooks/format-kotlin.sh; fi",
"statusMessage": "Formatting Kotlin sources",
"timeout": 300
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "if [ -x .agent-rules/hooks/format-kotlin.sh ]; then exec .agent-rules/hooks/format-kotlin.sh; fi",
"statusMessage": "Formatting Kotlin sources",
"timeout": 300
}
]
}
]
}
3 changes: 3 additions & 0 deletions check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# CI entry point: fails when the project has drifted from the rules it pins.
exec "$(cd -- "$(dirname -- "$0")" && pwd)/install.sh" --check
23 changes: 23 additions & 0 deletions ci/github-actions/agent-rules-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy into .github/workflows/ of a project that mounts .agent-rules.
#
# Fails the pull request when the project's agent configuration no longer
# matches the rules revision it pins — usually because the submodule was bumped
# without re-running install.sh.
name: agent-rules drift

on:
pull_request:
push:
branches: [master, main]

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Without this the check runs against an empty .agent-rules directory.
submodules: true

- name: Check agent rules are in sync
run: ./.agent-rules/check.sh
121 changes: 121 additions & 0 deletions hooks/format-kotlin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Formats Kotlin sources touched during the turn.
#
# Wired to the Stop event of Claude Code and Codex alike: both hand the hook a
# JSON event on stdin, and both read exit code 2 with stderr as text to give
# back to the model. So one script serves both.
#
# Contract:
# exit 0 — nothing to do, or everything formatted cleanly
# exit 2 — ktlint found violations it cannot fix; stderr goes back to the agent
#
# It never fails the session for its own reasons: no Kotlin changes, no ktlint,
# no maven, no repository — all of these exit 0. Written against bash 3.2, which
# is still what ships with macOS.

set -uo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck source=lib/common.sh
. "$SCRIPT_DIR/lib/common.sh"

hook_read_payload

REPO_ROOT="$(hook_repo_root)" || exit 0
[ -n "$REPO_ROOT" ] || exit 0

CHANGED="$(hook_changed_files "$REPO_ROOT" '*.kt' '*.kts')"
# The common case is a turn that touched no Kotlin. Leave before paying for a JVM.
[ -n "$CHANGED" ] || exit 0

hook_load_project_env "$REPO_ROOT"

# Resolves the module directories to format: for each changed file, the nearest
# ancestor holding a pom.xml. A leaf module inherits the plugin from its parent,
# so running there is enough.
maven_module_dirs() {
printf '%s\n' "$CHANGED" | while IFS= read -r file; do
[ -n "$file" ] || continue
local dir
dir="$(dirname -- "$REPO_ROOT/$file")"
while [ "${#dir}" -ge "${#REPO_ROOT}" ]; do
if [ -f "$dir/pom.xml" ]; then
printf '%s\n' "$dir"
break
fi
dir="$(dirname -- "$dir")"
done
done | sort -u
}

project_has_ktlint_maven() {
find "$REPO_ROOT" -name pom.xml -not -path '*/target/*' -print0 2>/dev/null |
xargs -0 grep -l 'ktlint-maven-plugin' 2>/dev/null |
grep -q .
}

# Keeps the ktlint violation lines and drops maven's own failure boilerplate and
# JVM warnings, so the agent gets the findings rather than a wall of noise. Falls
# back to the raw output if the run failed for some reason other than lint.
extract_violations() {
local raw filtered
raw="$(cat)"
filtered="$(printf '%s\n' "$raw" | sed -n 's/^\[ERROR\] \(.*\.kts\{0,1\}:[0-9][0-9]*:[0-9][0-9]*: .*\)$/\1/p')"

if [ -n "$filtered" ]; then
printf '%s\n' "$filtered"
else
printf '%s\n' "$raw"
fi
}

run_maven_ktlint() {
# No subshell below: the loop is fed by a heredoc precisely so that a
# failure inside it survives into the return value.
local status=0
local dirs dir output

if ! command -v mvn >/dev/null 2>&1; then
hook_log "agent-rules: ktlint hook skipped, mvn is not on PATH"
return 0
fi

dirs="$(maven_module_dirs)"
[ -n "$dirs" ] || return 0

while IFS= read -r dir; do
[ -n "$dir" ] || continue
# Both goals in one invocation: format is silent about what it cannot
# fix — only check reports that — and a single mvn run means a single JVM.
if ! output="$(cd "$dir" && mvn --batch-mode -q -Dstyle.color=never ktlint:format ktlint:check 2>&1)"; then
status=1
printf '%s\n' "$output" | extract_violations
fi
done <<EOF
$dirs
EOF

return "$status"
}

# The runner is picked per project. Adding Gradle or the standalone CLI later
# means adding a branch here, not touching anything else.
if ! project_has_ktlint_maven; then
# Plenty of repositories mount this submodule without being Kotlin projects.
exit 0
fi

if FAILURE="$(run_maven_ktlint)"; then
exit 0
fi

MESSAGE="ktlint could not fix everything automatically. Fix the violations below before finishing:

$FAILURE"

if hook_should_block "$MESSAGE"; then
hook_log "$MESSAGE"
exit 2
fi

exit 0
Loading