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
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ projects as a git submodule.

The repository carries three things:

- `rules/` — the rules themselves, as plain markdown. Single source of truth.
- `rules/` — common rules and opt-in profiles, 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.
Top-level files in `rules/` hold rules that apply to the whole organization.
Rules shared by one family of services live in `rules/profiles/` and are selected
by the consuming project. Anything tied to one service — its build quirks and
local conventions — stays in that service's own `AGENTS.md` / `CLAUDE.md`,
outside the synced block.

## Adding to a project

Expand All @@ -25,14 +28,40 @@ git submodule add <repo-url> .agent-rules
`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`
- writes `@`-imports of the selected rule files 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.

## Rule profiles

Without configuration, `install.sh` applies only the common rules. A consuming
project can commit `.agent-rules-profile` with one of these values:

- `common` — common rules only;
- `openapi` — common rules and contract-first OpenAPI conventions;
- `adapter` — common rules and external-adapter conventions.

For example:

```text
openapi
```

The profile can be overridden for a single command. The same option is accepted
by `check.sh`:

```bash
./.agent-rules/install.sh --profile openapi
./.agent-rules/check.sh --profile openapi
```

The command-line value takes precedence over `.agent-rules-profile`. Unknown or
empty profile values are rejected.

## Updating

```bash
Expand All @@ -45,8 +74,9 @@ 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
`check.sh` runs `install.sh --check` with the configured profile: 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.

Expand Down
2 changes: 1 addition & 1 deletion check.sh
Original file line number Diff line number Diff line change
@@ -1,3 +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
exec "$(cd -- "$(dirname -- "$0")" && pwd)/install.sh" --check "$@"
36 changes: 0 additions & 36 deletions code-conventions.md

This file was deleted.

24 changes: 0 additions & 24 deletions database-conventions.md

This file was deleted.

75 changes: 65 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/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
# ./.agent-rules/install.sh apply common rules
# ./.agent-rules/install.sh --profile openapi apply a rule profile
# ./.agent-rules/install.sh --check [--profile ...] report drift, write nothing
#
# 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
Expand All @@ -18,15 +19,36 @@ HOOK_MARKER="format-kotlin.sh"

CHECK_ONLY=0
DRIFT=0
PROFILE_OVERRIDE=""

case "${1:-}" in
--check) CHECK_ONLY=1 ;;
"") ;;
*)
printf 'usage: %s [--check]\n' "$0" >&2
exit 64
;;
esac
usage() {
printf 'usage: %s [--check] [--profile common|openapi|adapter]\n' "$0" >&2
}

while [ "$#" -gt 0 ]; do
case "$1" in
--check)
CHECK_ONLY=1
shift
;;
--profile)
case "${2:-}" in
""|--*)
printf 'agent-rules: --profile requires a value\n' >&2
usage
exit 64
;;
esac
PROFILE_OVERRIDE="$2"
shift 2
;;
*)
printf 'agent-rules: unknown argument: %s\n' "$1" >&2
usage
exit 64
;;
esac
done

command -v jq >/dev/null 2>&1 || {
printf 'agent-rules: jq is required\n' >&2
Expand All @@ -47,6 +69,30 @@ case "$RULES_DIR" in
;;
esac

PROFILE="common"
PROFILE_FILE="$PROJECT_ROOT/.agent-rules-profile"

if [ -f "$PROFILE_FILE" ]; then
PROFILE="$(cat "$PROFILE_FILE")"
fi

if [ -n "$PROFILE_OVERRIDE" ]; then
PROFILE="$PROFILE_OVERRIDE"
fi

case "$PROFILE" in
common|openapi|adapter) ;;
"")
printf 'agent-rules: profile in %s is empty\n' "$PROFILE_FILE" >&2
exit 64
;;
*)
printf 'agent-rules: unknown profile: %s\n' "$PROFILE" >&2
printf 'agent-rules: expected common, openapi, or adapter\n' >&2
exit 64
;;
esac

report() {
if [ "$CHECK_ONLY" -eq 1 ]; then
printf 'drift: %s\n' "$1" >&2
Expand Down Expand Up @@ -118,6 +164,15 @@ merge_hooks() {

rule_files() {
find "$RULES_DIR/rules" -maxdepth 1 -name '*.md' -not -name 'index.md' | sort

case "$PROFILE" in
openapi)
printf '%s\n' "$RULES_DIR/rules/profiles/openapi.md"
;;
adapter)
printf '%s\n' "$RULES_DIR/rules/profiles/adapter.md"
;;
esac
}

# Path of a rule file relative to the project root, e.g. .agent-rules/rules/x.md
Expand Down
104 changes: 104 additions & 0 deletions rules/code-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Code conventions

## Architecture and dependencies

- Transport adapters handle protocol concerns only: they validate the transport
contract, delegate to a service, and translate failures into protocol errors.
- Services implement business scenarios, define the order of operations, and own
transaction boundaries.
- Complex changes to aggregate parts are delegated to focused handlers instead of
growing a single service class.
- Repositories encapsulate persistence and return database or domain models. They do
not build transport responses.
- External systems are hidden behind local client or service interfaces; generated
stubs and retry mechanics do not leak into business code.
- Spring dependencies are provided through constructor injection and stored in
`final`/`val` fields. Java components use Lombok's `@RequiredArgsConstructor`
instead of handwritten constructors when no custom initialization is required.
- Code is organized into the `config`, `config.properties`, `resource`,
`servlet`, `service`, `repository`, `repository.model`, `scheduler`, `client`,
`client.model`, `converter`, and `extensions` packages.
- Standalone classes and models are placed in separate files.

## DTOs and converters

- External API requests and responses are represented by typed DTOs, without
`Map<String, Any>`.
- Transport models are converted before reaching repositories. Simple entities may
use generated persistence models; aggregates use local domain models.
- JSON property names are specified with Jackson annotations, and closed sets of
values are represented by enums.
- Model conversion is performed by dedicated `@Component` classes implementing
Spring's `Converter<S, T>`.
- Requests and responses are created by converters.
- Converters map data but do not write to the database or call external systems.
- Optional fields are set only when present. An omitted value and an explicitly
empty value remain distinct when the API contract distinguishes them.
- Unsupported conversion directions fail explicitly instead of returning `null`.
- Concrete converters are provided through constructor injection.
- Related data for collections is loaded in batches before conversion; converters
must not introduce N+1 calls.

## Business operations

- Writes that form one business operation run in one transaction.
- Collection equality ignores order when order has no business meaning.
- One operation timestamp is reused for the persisted changes.

## REST-to-gRPC gateways

- Generated REST interfaces define the transport contract. Controllers and resources
implement them, validate transport concerns, and delegate without duplicating the
contract or containing business orchestration.
- Orchestration services build typed gRPC requests, invoke generated clients, and use
dedicated converters for REST-to-Protobuf and Protobuf-to-REST mapping.
- A request or correlation identifier received at the public boundary is propagated to
every downstream request and included in logs and typed error responses.
- gRPC failures are mapped centrally to the API's declared error model. At minimum,
invalid input, unauthenticated, forbidden, not found, conflict, throttling, deadline,
downstream unavailability, and unexpected internal failures remain distinguishable.
- Transport failures never produce an untyped or accidentally empty error response.

## Kotlin style

- Calls to regular functions and methods use positional arguments.
- Named arguments are allowed for constructors and annotations.
- Constants belonging to a single class are placed in its `private companion object`.
- Shared constants are placed in the appropriate `constants/*.kt` file.

## Configuration

- External integrations are replaced with mock or stub beans in tests.
- Settings are grouped into typed `@ConfigurationProperties`.
- Retry policies, backoff, and asynchronous executors are configured centrally and
injected by name.

## External clients

- The client is responsible for transport, the converter for mapping, and the service
for the business scenario.
- A client owns its generated stub and applies the configured retry policy in one
place.
- Missing recipients or input for an optional side effect causes an early return
without an external call.
- Asynchronous entry points catch and log failures that cannot be returned to the
caller.

## Errors and logging

- Expected domain failures use specific exception types and are mapped to transport
statuses at the transport boundary.
- Logs use parameterized placeholders instead of string concatenation and include
available request and domain identifiers.
- Large payloads and user content are logged only at `DEBUG` or `TRACE`.
- Transport adapters log request boundaries; services and handlers log business
steps without duplicating the full payload.

## Testing

- Pure converters and external-client orchestration are covered by unit tests,
including optional values, empty collections, invalid input, retries, and early
returns.
- External calls use mocks or stubs and assert the generated request as well as the
returned result.
- Asynchronous tests wait for an observable event instead of using a fixed `sleep`.
Loading