diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b69d09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# IDE +.idea/ +*.iml +.vscode/ + +# macOS +.DS_Store + +# Editor scratch +*.swp +*~ diff --git a/README.md b/README.md index 632d7a3..b1ff59f 100644 --- a/README.md +++ b/README.md @@ -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 .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 `` and + `` + +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. diff --git a/agents/claude/settings.hooks.json b/agents/claude/settings.hooks.json new file mode 100644 index 0000000..66fec0e --- /dev/null +++ b/agents/claude/settings.hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/agents/codex/hooks.json b/agents/codex/hooks.json new file mode 100644 index 0000000..137390b --- /dev/null +++ b/agents/codex/hooks.json @@ -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 + } + ] + } + ] +} diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..11cf16b --- /dev/null +++ b/check.sh @@ -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 diff --git a/ci/github-actions/agent-rules-drift.yml b/ci/github-actions/agent-rules-drift.yml new file mode 100644 index 0000000..d3d4b7f --- /dev/null +++ b/ci/github-actions/agent-rules-drift.yml @@ -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 diff --git a/hooks/format-kotlin.sh b/hooks/format-kotlin.sh new file mode 100755 index 0000000..73267c3 --- /dev/null +++ b/hooks/format-kotlin.sh @@ -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 <&2 +} + +# Reads the event JSON from stdin and populates HOOK_* variables. +# +# jq is used when available; the fallback covers the two scalar fields we +# actually need, so a machine without jq still gets a working hook. +hook_read_payload() { + HOOK_PAYLOAD="$(cat)" + [ -n "$HOOK_PAYLOAD" ] || return 0 + + if command -v jq >/dev/null 2>&1; then + # One jq for all three fields: this runs on every turn, so the process + # spawns are worth counting. + IFS=' ' read -r HOOK_SESSION_ID HOOK_CWD HOOK_STOP_ACTIVE </dev/null) +EOF + else + HOOK_SESSION_ID="$(hook_scalar_fallback session_id)" + HOOK_CWD="$(hook_scalar_fallback cwd)" + case "$HOOK_PAYLOAD" in + *'"stop_hook_active"'*'true'*) HOOK_STOP_ACTIVE="true" ;; + esac + fi + + [ "$HOOK_STOP_ACTIVE" = "true" ] || HOOK_STOP_ACTIVE="false" +} + +hook_scalar_fallback() { + printf '%s' "$HOOK_PAYLOAD" | + tr ',' '\n' | + sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | + head -n 1 +} + +# Echoes the repository root, or returns non-zero when there is no repository. +hook_repo_root() { + local dir="${HOOK_CWD:-$PWD}" + [ -d "$dir" ] || dir="$PWD" + git -C "$dir" rev-parse --show-toplevel 2>/dev/null +} + +# Projects declare their own build environment (JAVA_HOME, locale) here. The +# file belongs to the project; this repository only agrees to read it. +hook_load_project_env() { + local env_file="$1/.agent-rules.env" + [ -f "$env_file" ] || return 0 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + +# Echoes working-tree files matching the given globs, one per line, relative to +# the repository root: everything changed against HEAD plus untracked files. +# Build and generated output is filtered out. +hook_changed_files() { + local root="$1" + shift + + { + if git -C "$root" rev-parse --verify --quiet HEAD >/dev/null 2>&1; then + git -C "$root" diff --name-only --diff-filter=ACMR HEAD -- "$@" + else + git -C "$root" diff --name-only --diff-filter=ACMR --cached -- "$@" + fi + git -C "$root" ls-files --others --exclude-standard -- "$@" + } 2>/dev/null | sort -u | while IFS= read -r file; do + case "$file" in + target/* | */target/* | build/* | */build/* | out/* | */out/*) continue ;; + _generated/* | */_generated/* | */generated-sources/*) continue ;; + esac + [ -f "$root/$file" ] || continue + printf '%s\n' "$file" + done +} + +# Loop guard for hooks that block on Stop. +# +# Blocking makes the agent run again, which fires Stop again. The agent's own +# stop_hook_active flag covers Claude Code; this covers the general case by +# refusing to block twice in a row on an identical message. +hook_should_block() { + local message="$1" + local state_dir="${TMPDIR:-/tmp}/agent-rules-hooks" + local key="${HOOK_SESSION_ID:-nosession}" + local state_file + local digest + + [ "$HOOK_STOP_ACTIVE" = "true" ] && return 1 + + key="$(printf '%s' "$key" | tr -c 'A-Za-z0-9_.-' '_')" + state_file="$state_dir/$key.last" + digest="$(printf '%s' "$message" | cksum | tr -d ' \n')" + + if [ -f "$state_file" ] && [ "$(cat "$state_file" 2>/dev/null)" = "$digest" ]; then + return 1 + fi + + mkdir -p "$state_dir" 2>/dev/null || return 1 + printf '%s' "$digest" >"$state_file" 2>/dev/null || return 1 + return 0 +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a201810 --- /dev/null +++ b/install.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Wires the shared rules into the project that mounts this submodule. +# +# ./.agent-rules/install.sh apply +# ./.agent-rules/install.sh --check report drift, write nothing, exit 1 on drift +# +# Everything here is idempotent and owns a bounded piece of each file: the hook +# entries it registered, and the text between the agent-rules markers. Whatever +# else the project keeps in CLAUDE.md, AGENTS.md or its agent settings is left +# untouched. + +set -uo pipefail + +RULES_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +BEGIN_MARKER="" +END_MARKER="" +HOOK_MARKER="format-kotlin.sh" + +CHECK_ONLY=0 +DRIFT=0 + +case "${1:-}" in + --check) CHECK_ONLY=1 ;; + "") ;; + *) + printf 'usage: %s [--check]\n' "$0" >&2 + exit 64 + ;; +esac + +command -v jq >/dev/null 2>&1 || { + printf 'agent-rules: jq is required\n' >&2 + exit 1 +} + +PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { + printf 'agent-rules: run this from inside the consuming git repository\n' >&2 + exit 1 +} + +case "$RULES_DIR" in + "$PROJECT_ROOT"/*) ;; + *) + printf 'agent-rules: %s is not inside %s — run install.sh from the project that mounts it\n' \ + "$RULES_DIR" "$PROJECT_ROOT" >&2 + exit 1 + ;; +esac + +report() { + if [ "$CHECK_ONLY" -eq 1 ]; then + printf 'drift: %s\n' "$1" >&2 + DRIFT=1 + else + printf 'updated: %s\n' "$1" + fi +} + +# Writes $2 to $1 unless --check, in which case it only records the difference. +apply_file() { + local path="$1" desired="$2" label="$3" + + if [ -f "$path" ] && [ "$(cat "$path")" = "$desired" ]; then + return 0 + fi + + report "$label" + [ "$CHECK_ONLY" -eq 1 ] && return 0 + + mkdir -p "$(dirname -- "$path")" + printf '%s\n' "$desired" >"$path" +} + +# --- agent hook registration ------------------------------------------------- + +# Drops any previously registered entry for our hook, then appends the current +# one. That makes the merge both idempotent and self-healing when a project has +# edited the command by hand. +merge_hooks() { + local target="$1" fragment="$2" root_path="$3" label="$4" + local base desired + + base='{}' + [ -f "$target" ] && base="$(cat "$target")" + + desired="$(printf '%s' "$base" | jq --slurpfile frag "$fragment" --arg marker "$HOOK_MARKER" --arg root "$root_path" ' + ($frag[0] | getpath($root | split(".") | map(select(length > 0)))) as $events + | reduce ($events | keys[]) as $event ( + .; + setpath( + ($root | split(".") | map(select(length > 0))) + [$event]; + ( + (getpath(($root | split(".") | map(select(length > 0))) + [$event]) // []) + | map(select([.hooks[]?.command // ""] | map(contains($marker)) | any | not)) + ) + + $events[$event] + ) + ) + ')" || { + printf 'agent-rules: failed to merge %s\n' "$target" >&2 + exit 1 + } + + # Compare normalized so that key order and indentation never look like drift. + if [ -f "$target" ] && + [ "$(jq -S . "$target" 2>/dev/null)" = "$(printf '%s' "$desired" | jq -S .)" ]; then + return 0 + fi + + report "$label" + [ "$CHECK_ONLY" -eq 1 ] && return 0 + + mkdir -p "$(dirname -- "$target")" + printf '%s' "$desired" | jq . >"$target" +} + +# --- markdown block sync ----------------------------------------------------- + +rule_files() { + find "$RULES_DIR/rules" -maxdepth 1 -name '*.md' -not -name 'index.md' | sort +} + +# Path of a rule file relative to the project root, e.g. .agent-rules/rules/x.md +rule_rel_path() { + printf '%s' "${1#"$PROJECT_ROOT"/}" +} + +claude_block_body() { + printf '%s\n' "Shared organization rules, synced by .agent-rules/install.sh." + printf '\n' + rule_files | while IFS= read -r file; do + printf '@%s\n' "$(rule_rel_path "$file")" + done +} + +agents_block_body() { + printf '%s\n' "Shared organization rules, synced by .agent-rules/install.sh. Do not edit by hand." + rule_files | while IFS= read -r file; do + printf '\n' + cat "$file" + done +} + +# Replaces the marked block in $1 with $2, keeping everything outside it. Creates +# the file, or appends the block, when either is missing. +render_with_block() { + local path="$1" body="$2" + local block existing + + block="$BEGIN_MARKER +$body +$END_MARKER" + + if [ ! -f "$path" ]; then + printf '%s' "$block" + return 0 + fi + + existing="$(cat "$path")" + + if ! printf '%s' "$existing" | grep -qF "$BEGIN_MARKER"; then + printf '%s\n\n%s' "$existing" "$block" + return 0 + fi + + BLOCK="$block" awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" ' + index($0, begin) == 1 { print ENVIRON["BLOCK"]; skipping = 1; next } + index($0, end) == 1 && skipping { skipping = 0; next } + !skipping { print } + ' "$path" +} + +# --- run --------------------------------------------------------------------- + +merge_hooks "$PROJECT_ROOT/.claude/settings.json" \ + "$RULES_DIR/agents/claude/settings.hooks.json" \ + "hooks" \ + ".claude/settings.json" + +merge_hooks "$PROJECT_ROOT/.codex/hooks.json" \ + "$RULES_DIR/agents/codex/hooks.json" \ + "" \ + ".codex/hooks.json" + +apply_file "$PROJECT_ROOT/CLAUDE.md" \ + "$(render_with_block "$PROJECT_ROOT/CLAUDE.md" "$(claude_block_body)")" \ + "CLAUDE.md" + +apply_file "$PROJECT_ROOT/AGENTS.md" \ + "$(render_with_block "$PROJECT_ROOT/AGENTS.md" "$(agents_block_body)")" \ + "AGENTS.md" + +if [ "$CHECK_ONLY" -eq 1 ] && [ "$DRIFT" -eq 1 ]; then + printf '\nagent-rules: project has drifted from .agent-rules — run ./.agent-rules/install.sh\n' >&2 + exit 1 +fi + +exit 0 diff --git a/rules/code-generation.md b/rules/code-generation.md new file mode 100644 index 0000000..0487857 --- /dev/null +++ b/rules/code-generation.md @@ -0,0 +1,7 @@ +# Code generation + +- 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. diff --git a/rules/index.md b/rules/index.md new file mode 100644 index 0000000..61785de --- /dev/null +++ b/rules/index.md @@ -0,0 +1,7 @@ +# Shared engineering rules + +Organization-wide rules. They apply to every repository that mounts this +submodule; anything specific to a single service belongs in that service. + +- [Code generation](code-generation.md) +- [Protobuf](protobuf.md) diff --git a/protobuf.md b/rules/protobuf.md similarity index 100% rename from protobuf.md rename to rules/protobuf.md