- README.md: new root README with CI badge, per-service table, quick start, CI pipeline table, file budget docs, and links to production endpoints - CONTRIBUTING.md: dev environment setup, pre-commit checklist, commit marker reference, architecture non-negotiables, Claude Code section - CLAUDE.md: insert First-Time Setup & Claude Code Onboarding section with branch guard, hook explanation, guardrail marker table, and staging rules - REFACTOR_PLAYBOOK.md: commit existing refactor playbook doc - .gitignore: add dist/, .turbo/, pnpm-lock.yaml, .pnpm-store/ to prevent SDK build artifacts from appearing as untracked files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
40 KiB
1.9 AGENTS.python.md — Python / FastAPI conventions
# AGENTS.python.md — Python Service Conventions
## Layered architecture (FastAPI)
## 1. Guardrail files (drop these in first)
These artifacts enforce the rules without you or Claude having to remember them. Install them as **Phase 0**, before touching any real code.
### 1.1 `.claude/CLAUDE.md` — loaded into every Claude session
```markdown
# <Your Project Name>
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
> 3. **Do not touch the database schema.** No new migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner.
> 4. **Public endpoints are a contract.** Any change to a path/method/status/schema in a backend must be accompanied by a matching update in **every** consumer. OpenAPI snapshot tests in `tests/contracts/` are the gate.
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
>
> These rules apply to every Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this CLAUDE.md.
Keep project-specific notes (dev environment, URLs, tech stack) under this header.
1.2 .claude/settings.json — PreToolUse LOC hook
First line of defense. Blocks Write/Edit operations that would create or push a file past 500 lines. This stops Claude from ever producing oversized files.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md.\"}'; exit 0; fi",
"shell": "bash",
"timeout": 5
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing.\\\"}\"; fi; exit 0",
"shell": "bash",
"timeout": 5
}
]
}
]
}
}
1.3 .claude/rules/architecture.md — auto-loaded architecture rule
# Architecture Rules (auto-loaded)
Non-negotiable. Applied to every Claude Code session in this repo.
## File-size budget
- **Soft target:** 300 lines. **Hard cap:** 500 lines.
- Enforced by PreToolUse hook, pre-commit hook, and CI.
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require `[guardrail-change]` in the commit message. This list should SHRINK over time.
## Clean architecture
- Python: see `AGENTS.python.md`. Layering: api → services → repositories → db.models.
- Go: see `AGENTS.go.md`. Standard Go Project Layout + hexagonal.
- TypeScript: see `AGENTS.typescript.md`. Server-by-default, push client boundary deep, colocate `_components/` and `_hooks/` per route.
## Database is frozen
- No new migrations. No `ALTER TABLE`. No column renames.
- Pre-commit hook blocks any change under `migrations/` unless commit message contains `[migration-approved]`.
## Public endpoints are a contract
- Any change to path/method/status/schema must update every consumer in the same change set.
- OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
## Tests
- New code without tests fails CI.
- Refactors preserve coverage. Before splitting an oversized file, add a characterization test pinning current behavior.
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
## Guardrails are protected
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message.
- If Claude thinks a rule is wrong, surface it to the user. Do not silently weaken.
## Tooling baseline
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
- Go: `golangci-lint` strict, `go vet`, table-driven tests.
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
- All: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
1.4 .claude/rules/loc-exceptions.txt
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
#
# Format: one repo-relative path per line. Comments start with '#'.
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
# Goal: this list SHRINKS over time.
# --- Example entries ---
# Static data catalogs — splitting fragments lookup tables without improving readability.
# src/catalogs/country-data.ts
# src/catalogs/industry-taxonomy.ts
# Generated files — regenerated from schemas.
# api/generated/types.ts
1.5 scripts/check-loc.sh
#!/usr/bin/env bash
# check-loc.sh — File-size budget enforcer. Soft: 300. Hard: 500.
#
# Usage:
# scripts/check-loc.sh # scan whole repo
# scripts/check-loc.sh --changed # only files changed vs origin/main
# scripts/check-loc.sh path/to/file.py # check specific files
# scripts/check-loc.sh --json # machine-readable output
# Exit codes: 0 clean, 1 hard violation, 2 bad invocation.
set -euo pipefail
SOFT=300
HARD=500
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
EXCEPTIONS_FILE="$REPO_ROOT/.claude/rules/loc-exceptions.txt"
CHANGED_ONLY=0; JSON=0; TARGETS=()
for arg in "$@"; do
case "$arg" in
--changed) CHANGED_ONLY=1 ;;
--json) JSON=1 ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
-*) echo "unknown flag: $arg" >&2; exit 2 ;;
*) TARGETS+=("$arg") ;;
esac
done
is_excluded() {
local f="$1"
case "$f" in
*/node_modules/*|*/.next/*|*/.git/*|*/dist/*|*/build/*|*/__pycache__/*|*/vendor/*) return 0 ;;
*/migrations/*|*/alembic/versions/*) return 0 ;;
*_test.go|*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) return 0 ;;
*/tests/*|*/test/*) return 0 ;;
*.md|*.json|*.yaml|*.yml|*.lock|*.sum|*.mod|*.toml|*.cfg|*.ini) return 0 ;;
*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.woff|*.woff2|*.ttf) return 0 ;;
*.generated.*|*.gen.*|*_pb.go|*_pb2.py|*.pb.go) return 0 ;;
esac
return 1
}
is_in_exceptions() {
[[ -f "$EXCEPTIONS_FILE" ]] || return 1
local rel="${1#$REPO_ROOT/}"
grep -Fxq "$rel" "$EXCEPTIONS_FILE"
}
collect_targets() {
if (( ${#TARGETS[@]} > 0 )); then printf '%s\n' "${TARGETS[@]}"
elif (( CHANGED_ONLY )); then
git -C "$REPO_ROOT" diff --name-only --diff-filter=AM origin/main...HEAD 2>/dev/null \
|| git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD
else git -C "$REPO_ROOT" ls-files; fi
}
violations_hard=(); violations_soft=()
while IFS= read -r f; do
[[ -z "$f" ]] && continue
abs="$f"; [[ "$abs" != /* ]] && abs="$REPO_ROOT/$f"
[[ -f "$abs" ]] || continue
is_excluded "$abs" && continue
is_in_exceptions "$abs" && continue
loc=$(wc -l < "$abs" | tr -d ' ')
if (( loc > HARD )); then violations_hard+=("$loc $f")
elif (( loc > SOFT )); then violations_soft+=("$loc $f"); fi
done < <(collect_targets)
if (( JSON )); then
printf '{"hard":['
first=1; for v in "${violations_hard[@]}"; do
loc="${v%% *}"; path="${v#* }"
(( first )) || printf ','; first=0
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
done
printf '],"soft":['
first=1; for v in "${violations_soft[@]}"; do
loc="${v%% *}"; path="${v#* }"
(( first )) || printf ','; first=0
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
done
printf ']}\n'
else
if (( ${#violations_soft[@]} > 0 )); then
echo "::warning:: $((${#violations_soft[@]})) file(s) exceed soft target ($SOFT lines):"
printf ' %s\n' "${violations_soft[@]}" | sort -rn
fi
if (( ${#violations_hard[@]} > 0 )); then
echo "::error:: $((${#violations_hard[@]})) file(s) exceed HARD CAP ($HARD lines) — split required:"
printf ' %s\n' "${violations_hard[@]}" | sort -rn
fi
fi
(( ${#violations_hard[@]} == 0 ))
Make executable: chmod +x scripts/check-loc.sh.
1.6 scripts/githooks/pre-commit
#!/usr/bin/env bash
# pre-commit — enforces structural guardrails.
#
# 1. Blocks commits that introduce a non-test, non-generated source file > 500 LOC.
# 2. Blocks commits touching migrations/ unless commit message contains [migration-approved].
# 3. Blocks edits to guardrail files unless [guardrail-change] is in the commit message.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACM)
[[ ${#staged[@]} -eq 0 ]] && exit 0
# 1. LOC budget on staged files.
loc_targets=()
for f in "${staged[@]}"; do
[[ -f "$REPO_ROOT/$f" ]] && loc_targets+=("$REPO_ROOT/$f")
done
if [[ ${#loc_targets[@]} -gt 0 ]]; then
if ! "$REPO_ROOT/scripts/check-loc.sh" "${loc_targets[@]}"; then
echo; echo "Commit blocked: file-size budget violated."
echo "Split the file (preferred) or add to .claude/rules/loc-exceptions.txt."
exit 1
fi
fi
# 2. Migrations frozen unless approved.
if printf '%s\n' "${staged[@]}" | grep -qE '(^|/)(migrations|alembic/versions)/'; then
if ! grep -q '\[migration-approved\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
echo "Commit blocked: this change touches a migrations directory."
echo "Add '[migration-approved]' to your commit message if approved."
exit 1
fi
fi
# 3. Guardrail files protected.
guarded='^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$'
if printf '%s\n' "${staged[@]}" | grep -qE "$guarded"; then
if ! grep -q '\[guardrail-change\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
echo "Commit blocked: this change modifies guardrail files."
echo "Add '[guardrail-change]' to your commit message and explain why in the body."
exit 1
fi
fi
exit 0
1.7 scripts/install-hooks.sh
#!/usr/bin/env bash
# install-hooks.sh — installs git hooks that enforce repo guardrails locally.
# Idempotent. Safe to re-run. Run once per clone: bash scripts/install-hooks.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOKS_DIR="$REPO_ROOT/.git/hooks"
SRC_DIR="$REPO_ROOT/scripts/githooks"
[[ -d "$REPO_ROOT/.git" ]] || { echo "Not a git repository: $REPO_ROOT" >&2; exit 1; }
mkdir -p "$HOOKS_DIR"
for hook in pre-commit; do
src="$SRC_DIR/$hook"; dst="$HOOKS_DIR/$hook"
if [[ -f "$src" ]]; then cp "$src" "$dst"; chmod +x "$dst"; echo "installed: $dst"; fi
done
echo "Done. Hooks active for this clone."
1.8 CI additions (.github/workflows/ci.yaml or .gitea/workflows/ci.yaml)
Add a loc-budget job that fails on hard violations:
jobs:
loc-budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check file-size budget
run: bash scripts/check-loc.sh --changed
python-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ruff
run: pip install ruff && ruff check .
- name: mypy on new modules
run: pip install mypy && mypy --strict services/ repositories/ domain/
go-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with: { version: latest }
ts-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npx tsc --noEmit && npx next build
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/contracts/ -v
license-sbom-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: anchore/sbom-action@v0
- uses: anchore/scan-action@v3
1.9 AGENTS.python.md (Python / FastAPI)
# AGENTS.python.md — Python Service Conventions
## Layered architecture
```
<service>/
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
│ └── <domain>_routes.py
├── services/ # Business logic. Pure-ish; no FastAPI imports.
├── repositories/ # DB access. Owns SQLAlchemy session usage.
├── domain/ # Value objects, enums, domain exceptions.
├── schemas/ # Pydantic models, split per domain. Never one giant schemas.py.
└── db/models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
```
Dependency direction: `api → services → repositories → db.models`. Lower layers must not import upper.
## Routers
- One `APIRouter` per domain file. Handlers ≤30 LOC.
- Parse request → call service → map domain errors → return response model.
- Inject services via `Depends`. No globals.
```python
@router.post("/items", response_model=ItemRead, status_code=201)
async def create_item(
payload: ItemCreate,
service: ItemService = Depends(get_item_service),
tenant_id: UUID = Depends(get_tenant_id),
) -> ItemRead:
with translate_domain_errors():
return await service.create(tenant_id, payload)
```
## Domain errors + translator
```python
# domain/errors.py
class DomainError(Exception): ...
class NotFoundError(DomainError): ...
class ConflictError(DomainError): ...
class ValidationError(DomainError): ...
class PermissionError(DomainError): ...
# api/_http_errors.py
from contextlib import contextmanager
from fastapi import HTTPException
@contextmanager
def translate_domain_errors():
try: yield
except NotFoundError as e: raise HTTPException(404, str(e)) from e
except ConflictError as e: raise HTTPException(409, str(e)) from e
except ValidationError as e: raise HTTPException(400, str(e)) from e
except PermissionError as e: raise HTTPException(403, str(e)) from e
```
## Services
- Constructor takes repository interface, not concrete.
- No FastAPI / HTTP knowledge.
- Raise domain exceptions, never HTTPException.
## Repositories
- Intent-named methods (`get_pending_for_tenant`), not CRUD-named (`select_where`).
- Session injected. No business logic.
- Return ORM models or domain VOs; never `Row`.
## Schemas (Pydantic v2)
- One module per domain. ≤300 lines.
- `model_config = ConfigDict(from_attributes=True, frozen=True)` for reads.
- Separate `*Create`, `*Update`, `*Read`.
## Tests
- `tests/unit/`, `tests/integration/`, `tests/contracts/`.
- Unit tests mock repository via `AsyncMock`.
- Integration tests use real Postgres from compose via transactional fixture (rollback per test).
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
- Naming: `test_<unit>_<scenario>_<expected>.py::TestX::test_method`.
- `pytest-asyncio` mode = `auto`. Coverage target: 80% new code.
## Tooling
- `ruff check` + `ruff format` (line length 100).
- `mypy --strict` on `services/`, `repositories/`, `domain/` first. Expand outward via per-module overrides in mypy.ini:
```ini
[mypy]
strict = True
[mypy-<service>.services.*]
strict = True
[mypy-<service>.legacy.*]
# Legacy modules not yet refactored — expand strictness over time.
ignore_errors = True
```
## What you may NOT do
- Add a new migration.
- Rename `__tablename__`, column, or enum value.
- Change route contract without simultaneous consumer update.
- Catch `Exception` broadly.
- Put business logic in a router or a Pydantic validator.
- Create a file > 500 lines.
1.10 AGENTS.go.md (Go / Gin or chi)
# AGENTS.go.md — Go Service Conventions
## Layered architecture (Standard Go Project Layout + hexagonal)
```
<service>/
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. < 50 LOC.
├── internal/
│ ├── app/ # Wiring: config + DI + lifecycle.
│ ├── domain/<aggregate>/ # Pure types, interfaces, errors. No I/O.
│ ├── service/<aggregate>/ # Business logic. Depends on domain interfaces.
│ ├── repository/postgres/<aggregate>/ # Concrete repos.
│ ├── transport/http/
│ │ ├── handler/<aggregate>/
│ │ ├── middleware/
│ │ └── router.go
│ └── platform/ # DB pool, logger, config, tracing.
└── pkg/ # Importable by other repos. Empty unless needed.
```
Direction: `transport → service → domain ← repository`. `domain` imports no siblings.
## Handlers
- ≤40 LOC. Bind → call service → map error via `httperr.Write(c, err)` → respond.
```go
func (h *ItemHandler) Create(c *gin.Context) {
var req CreateItemRequest
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Write(c, httperr.BadRequest(err)); return
}
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
if err != nil { httperr.Write(c, err); return }
c.JSON(http.StatusCreated, out)
}
```
## Errors — single `httperr` package
```go
switch {
case errors.Is(err, domain.ErrNotFound): return 404
case errors.Is(err, domain.ErrConflict): return 409
case errors.As(err, &validationErr): return 422
default: return 500
}
```
Never `panic` in request handling. Recovery middleware logs and returns 500.
## Services
- Struct + constructor + interface methods. No package-level state.
- `context.Context` first arg always.
- Return `(value, error)`. Wrap with `fmt.Errorf("create item: %w", err)`.
- Domain errors as sentinel vars or typed; match with `errors.Is` / `errors.As`.
## Repositories
- Interface in `domain/<aggregate>/repository.go`. Impl in `repository/postgres/<aggregate>/`.
- One file per query group; no file > 500 LOC.
- `pgx`/`sqlc` over hand-rolled SQL. No ORM globals. Everything takes `ctx`.
## Tests
- Co-located `*_test.go`. Table-driven for service logic.
- Handlers via `httptest.NewRecorder`.
- Repos via `testcontainers-go` (or the compose Postgres). Never mocks at SQL boundary.
- Coverage target: 80% on `service/`.
## Tooling (`golangci-lint` strict config)
- Linters: `errcheck, govet, staticcheck, revive, gosec, gocyclo(max 15), gocognit(max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
- `gofumpt` formatting. `go vet ./...` clean. `go mod tidy` clean.
## What you may NOT do
- Touch DB schema/migrations.
- Add a new top-level package under `internal/` without review.
- `import "C"`, unsafe, reflection-heavy code.
- Non-trivial setup in `init()`. Wire in `internal/app`.
- File > 500 lines.
- Change route contract without updating consumers.
1.11 AGENTS.typescript.md (TypeScript / Next.js)
# AGENTS.typescript.md — TypeScript / Next.js Conventions
## Layered architecture (Next.js 15 App Router)
```
app/
├── <route>/
│ ├── page.tsx # Server Component by default. ≤200 LOC.
│ ├── layout.tsx
│ ├── _components/ # Private folder; colocated UI. Each file ≤300 LOC.
│ ├── _hooks/ # Client hooks for this route.
│ ├── _server/ # Server actions, data loaders for this route.
│ └── loading.tsx / error.tsx
├── api/<domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
lib/
├── <domain>/ # Pure helpers, types, zod schemas. Reusable.
└── server/<domain>/ # Server-only logic; uses "server-only".
components/ # Truly shared, app-wide components.
```
Server vs Client: default is Server Component. Add `"use client"` only when state/effects/browser APIs needed. Push client boundary as deep as possible.
## API routes (route.ts)
- One handler per HTTP method, ≤40 LOC.
- Validate with `zod`. Reject invalid → 400.
- Delegate to `lib/server/<domain>/`.
```ts
export async function POST(req: Request) {
const parsed = CreateItemSchema.safeParse(await req.json());
if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const result = await itemService.create(parsed.data);
return NextResponse.json(result, { status: 201 });
}
```
## Page components
- Pages > 300 lines → split into colocated `_components/`.
- Server Components fetch data; pass plain objects to Client Components.
- No data fetching in `useEffect` for server-renderable data.
- State: prefer URL state (`searchParams`) + Server Components over global stores.
## Types — barrel re-export pattern for splitting monolithic type files
```ts
// lib/sdk/types/index.ts
export * from './enums'
export * from './vendor'
export * from './dsfa'
// consumers still `import { Foo } from '@/lib/sdk/types'`
```
Rules: no `any`. No `as unknown as`. All DTOs are zod schemas; infer via `z.infer`.
## Tests
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
- Hooks: `@testing-library/react` `renderHook`.
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page minimum.
- Coverage: 70% on `lib/`, smoke on `app/`.
## Tooling
- `tsc --noEmit` clean (strict, `noUncheckedIndexedAccess: true`).
- ESLint with `@typescript-eslint`, type-aware rules on.
- `next build` clean. No `@ts-ignore`. `@ts-expect-error` only with a reason comment.
## What you may NOT do
- Business logic in `page.tsx` or `route.ts`.
- Cross-app module imports.
- `dangerouslySetInnerHTML` without explicit sanitization.
- Backend API calls from Client Components when a Server Component/Action would do.
- Change route contract without updating consumers in the same change.
- File > 500 lines.
- Globally disable lint/type rules — fix the root cause.
2. Phase plan — behavior-preserving refactor
Work in phases. Each phase ends green (tests pass, build clean, contract baseline unchanged). Do not skip ahead.
Phase 0 — Foundation (single PR, low risk)
Goal: Set up rails. No code refactors yet.
- Drop in all files from Section 1. Install hooks:
bash scripts/install-hooks.sh. - Populate
.claude/rules/loc-exceptions.txtwith grandfathered entries (one line each, with a comment rationale) so CI doesn't fail day 1. - Append the non-negotiable rules block to root
CLAUDE.md. - Add per-language
AGENTS.*.mdat repo root. - Add the CI jobs from §1.8.
- Per-service
README.md+CLAUDE.mdstubs: what it does, run/test commands, layered architecture diagram, env vars, API surface link.
Verification: CI green; loc-budget job passes with allowlist; next Claude session loads the rules automatically.
Phase 1 — Backend service (Python/FastAPI)
Critical targets: any routes.py / schemas.py / repository.py / models.py over 500 LOC.
Steps:
- Snapshot the API contract:
curl /openapi.json > tests/contracts/openapi.baseline.json. Add a contract test that diffs current vs baseline and fails on any path/method/param drift. - Characterization tests first. For each oversized route file, add
TestClienttests exercising every endpoint (happy path + one error path). Usehttpx.AsyncClient+ factory fixtures. - Split models.py per aggregate. Keep a shim:
from <service>.db.models import *re-exports so existing imports keep working. One module per aggregate;__tablename__unchanged (no migration). - Split schemas.py similarly with a re-export shim.
- Extract service layer. Each route handler delegates to a
*Serviceclass injected viaDepends. Handlers shrink to ≤30 LOC. - Repository extraction from the giant repository file; one class per aggregate.
mypy --strictscoped to new packages first. Expand outward viamypy.iniper-module overrides.- Tests: unit tests per service (mocked repo), repo tests against a transactional fixture (real Postgres), integration tests at API layer.
Gotchas we hit:
- Tests that patch module-level symbols (e.g.
SessionLocal,scan_X) break when you move logic behindDepends. Fix: re-export the symbol from the route module, or have the service lookup use the module-level symbol directly so the patch still takes effect. from __future__ import annotationscan break Pydantic TypeAdapter forward refs. Remove it where it conflicts.- Sibling test file status codes drift when you introduce the domain-error translator (e.g. 422 → 400). Update assertions in the same commit.
Verification: all pytest files green. Characterization tests green. Contract test green (no drift). mypy clean on new packages. Coverage ≥ baseline + 10%.
Phase 2 — Go backend
Critical targets: any handler / store / rules file over 500 LOC.
Steps:
- OpenAPI/Swagger snapshot (or generate via
swag) → contract tests. - Generate handler-level tests with
httptestfor every endpoint pre-refactor. - Define hexagonal layout (see AGENTS.go.md). Move incrementally with type aliases for back-compat where needed.
- Replace ad-hoc error handling with
errors.Is/As+ a singlehttperrpackage. - Add
golangci-lintstrict config; fix new findings only (don't chase legacy lint). - Table-driven service tests.
testcontainers-gofor repo layer.
Verification: go test ./... passes; golangci-lint run clean; contract tests green; no DB schema diff.
Phase 3 — Frontend (Next.js)
Biggest beast — expect this to dominate. Critical targets: page.tsx / monolithic types / API routes over 500 LOC.
Per oversized page:
- Extract presentational components into
app/<route>/_components/(private folder, Next.js convention). - Move data fetching into Server Components / Server Actions; Client Components become small.
- Hooks →
app/<route>/_hooks/. - Pure helpers →
lib/<domain>/. - Add Vitest unit tests for hooks and pure helpers; Playwright smoke tests for each top-level page.
Monolithic types file: use barrel re-export pattern.
- Create
types/directory with domain files. - Create
types/index.tswithexport * from './<domain>'lines. - Critical: TypeScript won't allow both
types.tsANDtypes/index.ts— delete the file, atomic swap to directory.
API routes (route.ts): same router→service split as backend. Each route.ts becomes a thin handler delegating to lib/server/<domain>/.
Endpoint preservation: if any internal route URL changes, grep every consumer (SDK packages, developer portal, sibling apps) and update in the same change.
Gotchas:
- Pre-existing type bugs often surface when you try to build. Fix them as drive-by if they block your refactor; otherwise document in a separate follow-up.
useClientcomponent imports from'../provider'that rely on re-exports: preserve the re-export or update importers in the same commit.- Next.js build can fail at page-manifest stage with unrelated prerender errors. Run
next buildfresh (not from cache) to see real status.
Verification: next build clean; tsc --noEmit clean; Playwright smoke tests pass; visual diff check on key pages (manual + screenshots in PR).
Phase 4 — SDKs & smaller services
Apply the same patterns at smaller scale:
- SDK packages (0 tests): add Vitest unit tests for public surface before/while splitting.
- Manager/Client classes: extract config defaults, side-effect helpers (e.g. Google Consent Mode wiring), framework adapters into sibling files. Keep the main class as orchestration.
- Framework adapters (React/Vue/Angular): each component/composable/service/module goes in its own sibling file; the entry
index.tsis a thin barrel of re-exports. - Doc monoliths (
index.mdthousands of lines): split per topic with mkdocs nav.
Phase 5 — CI hardening & governance
- Promote
loc-budgetfrom warning → blocking once the allowlist has drained to legitimate exceptions only. - Add mutation testing in nightly (
mutmutfor Python,gomutestingfor Go). - Add
dependabot/renovatefor npm + pip + go mod. - Add release tagging workflow.
- Write ADRs (
docs/adr/) capturing the architecture decisions from phases 1–3. - Distill recurring patterns into
.claude/rules/updates.
3. Agent prompt templates
When the work volume is big, parallelize with subagents. These prompts were battle-tested in practice.
3.1 Backend route file split (Python)
You are working in
<repo>on branch<branch>. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300.Task: split
<path/to/file>_routes.py(NNN LOC) following the router → service → repository layering described inAGENTS.python.md.Steps:
- Snapshot the relevant slice of
/openapi.jsonand add a contract test that pins current behavior.- Add characterization tests for every endpoint in this file (happy path + one error path) using
httpx.AsyncClient.- Extract each route handler's business logic into a
<domain>Serviceclass in<service>/services/<domain>_service.py. Inject viaDepends(get_<domain>_service).- Raise domain errors (
NotFoundError,ConflictError,ValidationError), neverHTTPException. Use thetranslate_domain_errors()context manager in handlers.- Move DB access to
<service>/repositories/<domain>_repository.py. Session injected.- Split Pydantic schemas from the giant
schemas.pyinto<service>/schemas/<domain>.pyif >300 lines.Constraints:
- Behavior preservation. No route rename/method/status/schema changes.
- Tests that patch module-level symbols must keep working — re-export the symbol or refactor the lookup so the patch still takes effect.
- Run
pytestafter each step. Commit each file as its own commit.- Push at end:
git push origin <branch>.When done, report: (a) new LOC counts, (b) test results, (c) mypy status, (d) commit SHAs. Under 300 words.
3.2 Go handler file split
You are working in
<repo>on branch<branch>. Hard cap 500 LOC.Task: split
<path>/handlers/<domain>_handler.go(NNN LOC) into a hexagonal layout perAGENTS.go.md.Steps:
- Add
httptesttests for every endpoint pre-refactor.- Define
internal/domain/<aggregate>/with types + interfaces + sentinel errors.- Create
internal/service/<aggregate>/with business logic implementing domain interfaces.- Create
internal/repository/postgres/<aggregate>/splitting queries by group.- Thin handlers under
internal/transport/http/handler/<aggregate>/. Each handler ≤40 LOC. Error mapping viainternal/platform/httperr.- Use
errors.Is/errors.Asfor domain error matching.Constraints:
- No DB schema change.
- Table-driven service tests.
testcontainers-go(or compose Postgres) for repo tests.golangci-lint runclean.Report new LOC, test status, lint status, commit SHAs. Under 300 words.
3.3 Next.js page split (the one we parallelized heavily)
You are working in
<repo>on branch<branch>. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300. Other agents are working on OTHER pages in parallel — stay in your lane.Task: split the following Next.js 15 App Router client pages into colocated components so each
page.tsxdrops below 500 LOC.
admin-compliance/app/sdk/<page-a>/page.tsx(NNNN LOC)admin-compliance/app/sdk/<page-b>/page.tsx(NNNN LOC)Pattern (reference
admin-compliance/app/sdk/<already-split-example>/for "done"):
- Create
_components/subdirectory (Next.js private folder, won't create routes).- Extract each logically-grouped section (forms, tables, modals, tabs, headers, cards) into its own component file. Name files after the component.
- Create
_hooks/for custom hooks that were inline.- Create
_types.tsor_data.tsfor hoisted types or data arrays.- Remaining
page.tsxwires extracted pieces — aim for under 300 LOC, hard cap 500.- Preserve
'use client'when present on original.- DO NOT rename any exports that other files import. Grep first before moving.
Constraints:
- Behavior preservation. No logic changes, no improvements.
- Imports must resolve (relative
./_components/Foo).- Run
cd admin-compliance && npx next buildafter each file is done. Don't commit broken builds.- DO NOT edit
.claude/settings.json,scripts/check-loc.sh,loc-exceptions.txt, or anyAGENTS.*.md.- Commit each page as its own commit:
refactor(admin): split <name> page.tsx into colocated components. HEREDOC body, includeCo-Authored-By:trailer.- Pull before push:
git pull --rebase origin <branch>, thengit push origin <branch>.Coordination: DO NOT touch
<list of pages other agents own>. You own only<your pages>.When done, report: (a) each file's new LOC count, (b) how many
_componentswere created, (c) whethernext buildis clean, (d) commit SHAs. Under 300 words.If the LOC hook blocks a Write, split further. If you hit rate limits partway, commit what's done and report progress honestly.
3.4 Monolithic types file split (TypeScript)
<repo>, branch<branch>. Hard cap 500 LOC.Task: split
<lib>/types.ts(NNNN LOC) into per-domain modules under<lib>/types/.Steps:
- Identify domain groupings (enums, API DTOs, one group per business aggregate).
- Create
<lib>/types/directory with<domain>.tsfiles.- Create
<lib>/types/index.tsbarrel:export * from './<domain>'per file.- Atomic swap: delete the old
types.tsin the same commit as the newtypes/directory. TypeScript won't resolve both a file and a directory with the same stem.- Grep every consumer — imports from
'<lib>/types'should still work via the barrel. No consumer file changes needed unless there's a name collision.- Resolve collisions by renaming the less-canonical export (e.g. if two modules both export
LegalDocument, rename the RAG one toRagLegalDocument).Verification:
tsc --noEmitclean,next buildclean.Report new LOC per file, collisions resolved, consumer updates, commit SHAs.
3.5 Agent orchestration rules (from hard-won experience)
When you spawn multiple agents in parallel:
- Own disjoint paths. Give each agent a bounded list of files under specific directories. Spell out the "do NOT touch" list explicitly.
- Always instruct
git pull --rebase origin <branch>before push. Agents running in parallel will push and cause non-fast-forward rejects without this. - Instruct
commit each file as its own commit— not a single mega-commit. Makes revert surgical. - Ask for concise reports (≤300 words): new LOC counts, component counts, build status, commit SHAs.
- Tell them to commit partial progress on rate-limit. If they don't, their partial work lives in the working tree and you have to chase it with
git statusafter. (We hit this — 4 agents silently left uncommitted work.) - Don't give an agent more than 2 big files at once. Each page-split in practice took ~10–20 minutes + ~150k tokens. Two is a comfortable batch.
- Reference a prior "done" example. Commit SHAs are gold — the agent can inspect exactly the style you want.
- Run one final
next build/pytest/go testyourself after all agents finish. Agent reports of "build clean" can be scoped (e.g. only their files); you want the whole-repo gate.
4. Workflow loop (per file)
1. Read the oversized file end to end. Identify 3–6 extraction sections.
2. Write characterization test (if backend) — pin behavior.
3. Create the sibling files one at a time.
- If the PreToolUse hook blocks (file still > 500), split further.
4. Edit the root file: replace extracted bodies with imports + delegations.
5. Run the full verification: pytest / next build / go test.
6. Run LOC check: scripts/check-loc.sh <changed files>
7. Commit with a scoped message and a 1–2 line body explaining why.
8. Push.
5. Commit message conventions
refactor(<area>): <one-line what, not how>
<optional 1-3 sentence body: what split changed + verification result>
<LOC table: before → after per file>
<non-behavior changes flagged as drive-by fixes, with reason>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Markers that unlock pre-commit guards:
[migration-approved]— allows changes undermigrations//alembic/versions/.[guardrail-change]— allows changes to.claude/settings.json,.claude/rules/loc-exceptions.txt,scripts/check-loc.sh,scripts/githooks/pre-commit, or anyAGENTS.*.md.
Good examples from our session:
refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOCrefactor(compliance-sdk): split client/provider/embed/state under 500 LOCrefactor(admin): split whistleblower page.tsx + restore scope helperschore: document data-catalog + legacy-service LOC exceptions(with[guardrail-change]body)
6. Verification commands cheatsheet
# LOC budget
scripts/check-loc.sh --changed # only changed files
scripts/check-loc.sh # whole repo
scripts/check-loc.sh --json # for CI parsing
# Python
pytest --cov=<package> --cov-report=term-missing
ruff check .
mypy --strict <package>/services <package>/repositories
# Go
go test ./... -cover
golangci-lint run
go vet ./...
# TypeScript
npx tsc --noEmit
npx next build # from the Next.js app dir
npm test -- --run # vitest one-shot
npx playwright test tests/e2e # e2e smoke
# Contracts
pytest tests/contracts/ # OpenAPI snapshot diff
7. Out of scope (don't drift)
- DB schema / migrations — unless separate green-lit plan.
- New features. This is a refactor.
- Public endpoint renames without simultaneous consumer fix-up (exception: intra-monorepo URLs when you do the grep sweep).
- Unrelated dead code cleanup — do it in a separate PR.
- Bundling refactors across services in one commit — one service = one commit.
8. Memory / session handoff
If using Claude Code with persistent memory, save a project_refactor_status.md in your memory store after each phase:
- What's done (files split, LOC before → after).
- What's in progress (current file, blocker if any).
- What's deferred (pre-existing bugs surfaced but left for follow-up).
- Key patterns established (so next session doesn't rediscover them).
This lets you resume after context compacts or after rate-limit windows without losing the thread.
That's the whole methodology. Install Section 1, follow Section 2 phase-by-phase, use Section 3 to parallelize the grind. The guardrails do the policing so you don't have to remember anything.