diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 00000000..4627cb50 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,2 @@ +worktrees/ +.DS_Store diff --git a/.claude/agents/crewli-reviewer.md b/.claude/agents/crewli-reviewer.md new file mode 100644 index 00000000..ba71f9f7 --- /dev/null +++ b/.claude/agents/crewli-reviewer.md @@ -0,0 +1,77 @@ +--- +name: crewli-reviewer +description: Reviews code against Crewli's zero-compromise principles. Use after any backend or frontend implementation is complete, before committing. Returns a structured MUST FIX / SHOULD FIX / CONSIDER report. +tools: Read, Grep, Glob, Bash +model: claude-opus-4-7 +--- + +You are a staff engineer reviewing code for Crewli — a multi-tenant Laravel 12 + Vue 3 SaaS platform with zero tolerance for technical debt. + +Read /CLAUDE.md and the relevant /dev-docs/SCHEMA.md sections before reviewing. Do not patch code — produce a structured report only. + +## Review checklist (in order) + +### Multi-tenancy (highest priority — leaks cross-org data) +- Every business model has `OrganisationScope` registered in `booted()` (NOT just imported). +- Every business table FK chain reaches `organisations.id` within ≤2 hops. +- Every model has a Policy class registered in `AuthServiceProvider`. +- Public/unscoped query paths explicitly bypass the scope (`->withoutGlobalScope(OrganisationScope::class)`). +- Pest tests include a cross-org leak assertion: a record from org B must not appear when authenticated as org A. + +### Schema & types +- Primary keys: ULID on business tables, integer auto-increment on pure pivots — NEVER UUID v4. +- Status/type/category fields use a PHP Enum cast — NEVER string literals. +- Soft delete decision matches /dev-docs/SCHEMA.md — immutable audit records (check_ins, form_submissions) MUST NOT have softDeletes. +- JSON columns only for opaque config — never queryable data. +- Migration follows Laravel 12 anonymous-class style and uses `ulid('id')->primary()`. + +### Service & controller layer +- Business logic lives in `app/Services/Service.php` — NEVER in the controller. +- Controllers are thin: validate (FormRequest), delegate (Service), respond (Resource). +- Form Request validation rules cover every field, including enum cases. +- API Resource shapes the response — no raw `$model->toArray()`. + +### Activity log +- Every state-changing model uses the `LogsActivity` trait from spatie/laravel-activitylog. +- `getActivitylogOptions()` configured (logName, logFillable or logOnly, logOnlyDirty). +- Suppress logging in seeders/factories via `ActivityLog::suppressed()` where appropriate. + +### Queued jobs +- Jobs implement `ShouldQueue` and are idempotent. +- Side effects gated by a check (status flag, transient lock) so a re-run is safe. +- `tries`, `backoff`, `failed()` defined where retry semantics matter. + +### Cleanup +- Old code that the new feature replaces is DELETED, not adapted. No dead code paths, no duplicate implementations. + +### Frontend (apps/app or apps/portal) +- Component first checked against /dev-docs/VUEXY_COMPONENTS.md — Vuexy/Vuetify component used over hand-rolled HTML. +- Pinia store for shared state, TanStack Vue Query for server state — never raw axios in components. +- Forms use VeeValidate + Zod; error/empty/loading states explicitly rendered. +- TypeScript: no `any`. Strict types from API Resource shape. + +## The six most-missed gaps (always check explicitly) + +1. Business logic in controller instead of Service class. +2. String literals instead of PHP Enums. +3. Missing activity log on state-changing models. +4. Queued jobs not idempotent. +5. Replaced code not deleted (delete > adapt). +6. Frontend missing error/empty/loading states. + +## Output format + +Produce a single Markdown report with three sections: + +### MUST FIX (blocking — violates a zero-compromise principle) +- Bullet list. For each: `path/to/file.php:LINE` — issue — required change. + +### SHOULD FIX (non-blocking but clear improvement) +- Bullet list, same format. + +### CONSIDER (judgment call — flagged for Bert) +- Bullet list, same format. + +If the diff is clean: output `No issues found against the zero-compromise principles.` and stop. + +Always cite `file:line`. No vague feedback. No prose padding. diff --git a/.claude/commands/review-multitenancy.md b/.claude/commands/review-multitenancy.md new file mode 100644 index 00000000..486edd3a --- /dev/null +++ b/.claude/commands/review-multitenancy.md @@ -0,0 +1,26 @@ +--- +description: Check a model + migration + policy + tests for multi-tenancy correctness +argument-hint: [model-name] +allowed-tools: Read, Grep, Glob +--- + +Target: $ARGUMENTS + +Locate and read all four artefacts: +1. The Eloquent model — `app/Models/$ARGUMENTS.php` (or matching path under `app/Models/`). +2. The most recent migration that creates or modifies the table. +3. The policy class — `app/Policies/${ARGUMENTS}Policy.php`. +4. Pest tests for the model — search `tests/` for the class name. + +Verify each item against /dev-docs/SCHEMA.md and /CLAUDE.md, marking PASS / FAIL / N/A with one-line reasoning: + +- [ ] Primary key is ULID (`ulid('id')->primary()` in migration; `HasUlids` trait on model) +- [ ] `OrganisationScope` registered in model `booted()` (NOT merely imported) +- [ ] FK chain reaches `organisations.id` within ≤2 hops +- [ ] Policy class exists and is registered in `AuthServiceProvider::$policies` +- [ ] All status/type/category columns use a PHP Enum cast +- [ ] Soft delete decision matches the type's row in /dev-docs/SCHEMA.md +- [ ] Pest tests include a cross-org leak assertion +- [ ] Activity log trait present (if state-changing) + +End with a verdict line: `READY` or `NEEDS WORK`. diff --git a/.claude/commands/sprint-status.md b/.claude/commands/sprint-status.md new file mode 100644 index 00000000..95e143c8 --- /dev/null +++ b/.claude/commands/sprint-status.md @@ -0,0 +1,15 @@ +--- +description: Summarise current sprint position from BACKLOG.md, recent commits, and working tree +allowed-tools: Bash(git:*), Read +--- + +Read the first 50 lines of `dev-docs/BACKLOG.md`. +Run `git branch --show-current`, `git log --oneline -20`, and `git status -sb`. + +Produce a 5–10 line summary covering: +- Current branch and the work package it belongs to +- Last completed work package (most recent --no-ff merge in the log) +- Staged or unstaged work (uncommitted changes) +- Next item per BACKLOG.md + +No prose padding. Just the facts. diff --git a/.claude/commands/sync-docs.md b/.claude/commands/sync-docs.md new file mode 100644 index 00000000..5afd4d31 --- /dev/null +++ b/.claude/commands/sync-docs.md @@ -0,0 +1,11 @@ +--- +description: Run the dev-docs sync pipeline and remind to upload .claude-sync/ +allowed-tools: Bash(npm:*), Read +--- + +Run `npm run sync:docs`. +After it completes, read `.claude-sync/SYNC_MANIFEST.md` and print the `Git SHA` and `Generated` lines. + +End with this exact warning block: + +> ⚠️ Manual step required: upload `.claude-sync/` (including `SYNC_MANIFEST.md`) to Project Knowledge in Claude Chat. The drift-check protocol depends on this. Without upload the sync is stale and Claude Chat will work from outdated dev-docs. diff --git a/.claude/hooks/block-dangerous-bash.sh b/.claude/hooks/block-dangerous-bash.sh new file mode 100755 index 00000000..b06921fd --- /dev/null +++ b/.claude/hooks/block-dangerous-bash.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +input="$(cat)" +cmd="$(echo "$input" | jq -r '.tool_input.command // empty')" + +[ -z "$cmd" ] && exit 0 + +block() { + echo "Bash command blocked: $1. $2." >&2 + exit 2 +} + +# git reset --hard +if echo "$cmd" | grep -Eq 'git[[:space:]]+reset[[:space:]]+--hard'; then + block "git reset --hard destroys local work" "Use 'git stash' to set work aside, or branch off the current state before resetting" +fi + +# git push --force / -f +if echo "$cmd" | grep -Eq 'git[[:space:]]+push[[:space:]]+(--force([[:space:]]|=|$)|-f([[:space:]]|$))'; then + block "force push rewrites history" "Crewli uses --no-ff merges; never force-push. If the remote diverged, pull/rebase locally and resolve" +fi + +# rm -rf on absolute paths outside /tmp and /home// +if echo "$cmd" | grep -Eq '\brm[[:space:]]+-rf?[[:space:]]+/' && ! echo "$cmd" | grep -Eq '\brm[[:space:]]+-rf?[[:space:]]+/(tmp|var/folders|home/[^/[:space:]]+/[^[:space:]]|Users/[^/[:space:]]+/[^[:space:]])'; then + block "rm -rf on an absolute path outside /tmp" "Verify the path is project-relative; if you really need it, run it manually outside Claude Code" +fi + +# php artisan migrate:fresh — only with --env=testing +if echo "$cmd" | grep -Eq 'php[[:space:]]+artisan[[:space:]]+migrate:fresh\b'; then + if ! echo "$cmd" | grep -Eq -- '--env=testing\b'; then + block "migrate:fresh wipes the database" "Add --env=testing to scope this to the test database, or run a non-destructive 'migrate' / 'migrate:rollback'" + fi +fi + +# php artisan db:wipe — only with --env=testing +if echo "$cmd" | grep -Eq 'php[[:space:]]+artisan[[:space:]]+db:wipe\b'; then + if ! echo "$cmd" | grep -Eq -- '--env=testing\b'; then + block "db:wipe destroys the database" "Add --env=testing to scope this to the test database" + fi +fi + +# composer/pnpm/npm update +if echo "$cmd" | grep -Eq '\b(composer|pnpm|npm)[[:space:]]+update\b'; then + block "blanket dependency update bumps everything without review" "Use targeted 'composer require ' or 'pnpm add ' to bump one package at a time" +fi + +exit 0 diff --git a/.claude/hooks/inject-sprint-context.sh b/.claude/hooks/inject-sprint-context.sh new file mode 100755 index 00000000..da9b40f8 --- /dev/null +++ b/.claude/hooks/inject-sprint-context.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0 + +branch="$(git branch --show-current 2>/dev/null || echo unknown)" +recent="$(git log --oneline -10 2>/dev/null || echo '(git log unavailable)')" + +backlog_excerpt="(BACKLOG.md not found)" +if [ -f dev-docs/BACKLOG.md ]; then + # First 40 lines, capped at ~3500 chars to stay well under 600 tokens for the whole output. + backlog_excerpt="$(head -n 40 dev-docs/BACKLOG.md | head -c 3500)" +fi + +cat </dev/null || exit 0 +pnpm eslint --fix "$inside" >/dev/null 2>&1 || true +exit 0 diff --git a/.claude/hooks/post-edit-pint.sh b/.claude/hooks/post-edit-pint.sh new file mode 100755 index 00000000..eade3f73 --- /dev/null +++ b/.claude/hooks/post-edit-pint.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +input="$(cat)" +path="$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')" + +# No path or non-PHP — nothing to do. +[ -z "$path" ] && exit 0 +[[ "$path" != *.php ]] && exit 0 + +# Laravel app lives in api/. Pint binary is at api/vendor/bin/pint. +cd "$CLAUDE_PROJECT_DIR/api" 2>/dev/null || exit 0 +[ -x vendor/bin/pint ] || exit 0 + +# --dirty only formats files with git changes — fast even when called per-edit. +vendor/bin/pint --dirty >/dev/null 2>&1 || true +exit 0 diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh new file mode 100755 index 00000000..662bf266 --- /dev/null +++ b/.claude/hooks/protect-files.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +input="$(cat)" +path="$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')" + +[ -z "$path" ] && exit 0 + +block() { + echo "Edit to '$path' blocked: $1. $2." >&2 + exit 2 +} + +# .env files (but not .env.example) +if echo "$path" | grep -Eq '(^|/)\.env(\..*)?$' && ! echo "$path" | grep -Eq '(^|/)\.env\.example$'; then + block "secrets" "Propose changes to .env.example instead" +fi + +# composer.lock +if echo "$path" | grep -Eq '(^|/)composer\.lock$'; then + block "locked dependency tree" "Run composer require deliberately, then commit the regenerated lock file" +fi + +# JS lock files +if echo "$path" | grep -Eq '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$'; then + block "locked JS dependency tree" "Run pnpm add / npm install deliberately, then commit the regenerated lock file" +fi + +# Laravel default migrations +if echo "$path" | grep -Eq '(^|/)database/migrations/0001_01_01_.*\.php$'; then + block "Laravel default migration" "Never modify Laravel scaffold migrations — write a new migration that alters the table" +fi + +# apps/admin/ — deleted SPA per WS-3 +if echo "$path" | grep -Eq '(^|/)apps/admin/'; then + block "apps/admin/ was deleted in WS-3 and must not return" "Use apps/app/ (Organizer SPA, includes Platform Admin under /platform/*)" +fi + +# .claude/ tooling self-modification +if echo "$path" | grep -Eq '(^|/)\.claude/'; then + block "tooling self-modification — Bert reviews .claude/ changes by hand" "Open the file in an editor outside Claude Code, or ask Bert to authorize the change explicitly" +fi + +# dev-docs/SCHEMA.md +if echo "$path" | grep -Eq '(^|/)dev-docs/SCHEMA\.md$'; then + block "SCHEMA.md is updated only at sprint milestones" "Bert decides when SCHEMA snapshots roll forward — do not edit ad hoc" +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..869fd6fb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,50 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-bash.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-pint.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-eslint.sh" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-sprint-context.sh" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 50627bfc..905f3b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ # Crewli — Claude Code Instructions +> See `dev-docs/CLAUDE_CODE_TOOLING.md` for the deterministic guard-rail layer (hooks, subagent, slash commands). + ## Project context Crewli is a multi-tenant SaaS platform for event and festival management. diff --git a/dev-docs/CLAUDE_CODE_TOOLING.md b/dev-docs/CLAUDE_CODE_TOOLING.md new file mode 100644 index 00000000..c73b3abd --- /dev/null +++ b/dev-docs/CLAUDE_CODE_TOOLING.md @@ -0,0 +1,192 @@ +# Claude Code tooling — `.claude/` reference + +## 1. Purpose + +`/CLAUDE.md` is *advisory*: it tells Claude what to do. Claude is good +about following it, but advisory rules fail open — when Claude misses +a line, nothing catches it. Crewli runs a parallel *deterministic* +layer in `.claude/` that fires whether or not Claude reads it: hooks +on tool calls, a code-review subagent, and slash commands for the +procedural shortcuts that come up every sprint. + +The two layers are not redundant. They have different jobs. + +> **Binding principle:** Would Claude make a mistake without this +> rule? If no, delete it. If yes, hook it. CLAUDE.md is for +> understanding; `.claude/` is for guarantees. + +A rule belongs in `settings.json` / hooks if and only if Claude would +make a mistake without it. Otherwise the rule belongs nowhere — not +in CLAUDE.md, not in a hook. Don't double up: a hook rule that's also +documented in CLAUDE.md just rots when you change one and forget the +other. + +## 2. Layout + +``` +.claude/ +├── settings.json # Hook registry (committed) +├── settings.local.json # Per-user overrides (gitignored) +├── agents/ +│ └── crewli-reviewer.md # Code-review subagent +├── commands/ +│ ├── sprint-status.md # /sprint-status +│ ├── review-multitenancy.md # /review-multitenancy +│ └── sync-docs.md # /sync-docs +└── hooks/ + ├── protect-files.sh # PreToolUse Edit/Write + ├── block-dangerous-bash.sh # PreToolUse Bash + ├── post-edit-pint.sh # PostToolUse Edit/Write — PHP + ├── post-edit-eslint.sh # PostToolUse Edit/Write — JS/TS/Vue + └── inject-sprint-context.sh # SessionStart compact +``` + +Everything except `settings.local.json` is checked in. + +## 3. Hooks reference + +| Event | Matcher | Script | Behaviour | Fail mode | +|---|---|---|---|---| +| PreToolUse | `Edit\|Write\|MultiEdit` | `protect-files.sh` | Blocks edits to secrets, lock files, default migrations, the deleted `apps/admin/`, `.claude/` itself, and `dev-docs/SCHEMA.md`. | Exit 2 with reason on stderr. | +| PreToolUse | `Bash` | `block-dangerous-bash.sh` | Blocks `git reset --hard`, force pushes, blanket dependency updates, database wipes that aren't scoped to `--env=testing`, and `rm -rf` on absolute paths outside `/tmp`, `/var/folders`, and `$HOME`. | Exit 2 with reason on stderr. | +| PostToolUse | `Edit\|Write\|MultiEdit` | `post-edit-pint.sh` | Runs `vendor/bin/pint --dirty` from `api/` after any `.php` edit. | Exit 0 silently — formatting failures never block. | +| PostToolUse | `Edit\|Write\|MultiEdit` | `post-edit-eslint.sh` | Runs `pnpm eslint --fix` inside the matching SPA dir for `.vue/.ts/.tsx/.js` files under `apps/app/` or `apps/portal/`. | Exit 0 silently. | +| SessionStart | `compact` | `inject-sprint-context.sh` | Prints branch, last 10 commits, and the top of `BACKLOG.md` so Claude resumes with sprint context after auto-compaction. | Exit 0; output is appended to context. | + +Every script: + +- Sets `set -euo pipefail`. +- Reads JSON from stdin via `jq`. +- Resolves paths through `$CLAUDE_PROJECT_DIR`. +- Completes in well under 500 ms for typical inputs. + +PreToolUse hooks signal *block* by exiting `2` (never `1` — `1` is a +hook misconfiguration, not a policy denial). PostToolUse hooks always +exit `0`; a formatter that fails should not stop the agent. + +## 4. Subagent: `crewli-reviewer` + +Lives at `.claude/agents/crewli-reviewer.md`. Isolated context, +review-only — it has Read / Grep / Glob / Bash but no Edit. Designed +for a single pass against the zero-compromise principles in CLAUDE.md +and the schema rules in `dev-docs/SCHEMA.md`. + +### When to invoke + +After any non-trivial implementation, before committing: + +> Use the crewli-reviewer subagent on the changes since HEAD~1. + +### Report shape + +Three sections, every finding cited as `path/to/file.php:LINE`: + +- **MUST FIX** — blocking, violates a zero-compromise principle. +- **SHOULD FIX** — non-blocking but clearly correct improvement. +- **CONSIDER** — judgment call surfaced for Bert. + +If the diff is clean, the agent outputs `No issues found against the +zero-compromise principles.` and stops. + +### Extending the system prompt + +Edit `crewli-reviewer.md`. The frontmatter (`name`, `description`, +`tools`, `model`) is the discovery contract — keep `tools` minimal so +the reviewer can't accidentally patch code. Add new checklist items +under the existing checklist sections; if a recurring pattern of +review findings emerges, promote it to the *six most-missed gaps* +list at the bottom of the prompt. + +Don't move review checks into `CLAUDE.md` thinking it's redundant — +the subagent runs in an isolated context that may not have read +`CLAUDE.md` yet, so the prompt has to be self-contained. + +## 5. Slash commands + +| Command | Description | Example | +|---|---|---| +| `/sprint-status` | 5–10 line summary: current branch, last completed work package, uncommitted work, next BACKLOG item. | `/sprint-status` | +| `/review-multitenancy ` | Reads model + migration + policy + tests for `` and reports PASS/FAIL/N/A on the multi-tenancy checklist. Ends with `READY` or `NEEDS WORK`. | `/review-multitenancy Shift` | +| `/sync-docs` | Runs `npm run sync:docs`, prints the manifest's Git SHA + generation timestamp, and reminds you to upload `.claude-sync/` to Project Knowledge. | `/sync-docs` | + +Slash commands live in `.claude/commands/.md`. Frontmatter +declares `description`, optional `argument-hint`, and an +`allowed-tools` allowlist (least-privilege — `/sync-docs` only allows +`Bash(npm:*)` and `Read`, not arbitrary Bash). + +## 6. Testing a hook + +Each hook reads a single JSON object on stdin. Test by piping a +fixture and inspecting the exit code and stderr: + +```bash +# protect-files — should BLOCK +echo '{"tool_input":{"file_path":".env"}}' | bash .claude/hooks/protect-files.sh; echo "exit=$?" + +# protect-files — should ALLOW +echo '{"tool_input":{"file_path":"app/Models/Event.php"}}' | bash .claude/hooks/protect-files.sh; echo "exit=$?" + +# block-dangerous-bash — should BLOCK +echo '{"tool_input":{"command":"git push --force origin main"}}' | bash .claude/hooks/block-dangerous-bash.sh; echo "exit=$?" + +# block-dangerous-bash — should ALLOW (testing scope) +echo '{"tool_input":{"command":"php artisan migrate:fresh --env=testing --seed"}}' | bash .claude/hooks/block-dangerous-bash.sh; echo "exit=$?" + +# post-edit-pint — no-op on non-PHP +echo '{"tool_input":{"file_path":"README.md"}}' | bash .claude/hooks/post-edit-pint.sh; echo "exit=$?" + +# post-edit-eslint — no-op on PHP +echo '{"tool_input":{"file_path":"api/app/Models/Event.php"}}' | bash .claude/hooks/post-edit-eslint.sh; echo "exit=$?" + +# inject-sprint-context — full output +bash .claude/hooks/inject-sprint-context.sh +``` + +Block scenarios should print a reason on stderr and exit `2`; allow +scenarios should be silent and exit `0`. + +## 7. Disabling temporarily + +Set `"disableAllHooks": true` at the top level of `.claude/settings.json`: + +```json +{ + "disableAllHooks": true, + "hooks": { ... } +} +``` + +Document *why* in the commit message and revert before pushing the +branch you're working on. The hooks exist because they catch real +mistakes; turning them off is a knowingly-temporary state, not a +preference. + +For per-user overrides without touching the committed file, use +`.claude/settings.local.json` (gitignored). + +## 8. Adding a new hook + +Checklist: + +- [ ] Script completes in well under 500 ms (a slow hook makes every + tool call slower). +- [ ] PreToolUse: exit `2` to block (NEVER `1` — `1` reads as + misconfiguration). PostToolUse: always exit `0`. +- [ ] `set -euo pipefail` at the top. +- [ ] JSON parsed via `jq` from stdin (no shelling out to grep over + the whole payload). +- [ ] Path resolution via `$CLAUDE_PROJECT_DIR`, never `$PWD`. +- [ ] Test fixture in this document's section 6 covers the new + pattern. +- [ ] Entry added to the hooks reference table in section 3. +- [ ] `chmod +x` set, committed. + +## 9. Design principle (verbatim) + +Settings and hooks are deterministic; CLAUDE.md is advisory. They +serve different jobs and should never duplicate. If a rule is +hookable, hook it and *delete* it from CLAUDE.md — keeping both is +the worst of both worlds (the agent reads the rule, the hook fires +the rule, and when they diverge nobody knows which is right). +CLAUDE.md is for understanding the project; `.claude/` is for +catching the mistakes that happen anyway.