Merge WS-TOOLING-001: Claude Code deterministic guard-rail layer
This commit is contained in:
2
.claude/.gitignore
vendored
Normal file
2
.claude/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
worktrees/
|
||||||
|
.DS_Store
|
||||||
77
.claude/agents/crewli-reviewer.md
Normal file
77
.claude/agents/crewli-reviewer.md
Normal file
@@ -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/<Domain>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.
|
||||||
26
.claude/commands/review-multitenancy.md
Normal file
26
.claude/commands/review-multitenancy.md
Normal file
@@ -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`.
|
||||||
15
.claude/commands/sprint-status.md
Normal file
15
.claude/commands/sprint-status.md
Normal file
@@ -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.
|
||||||
11
.claude/commands/sync-docs.md
Normal file
11
.claude/commands/sync-docs.md
Normal file
@@ -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.
|
||||||
48
.claude/hooks/block-dangerous-bash.sh
Executable file
48
.claude/hooks/block-dangerous-bash.sh
Executable file
@@ -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/<user>/
|
||||||
|
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 <pkg>' or 'pnpm add <pkg>' to bump one package at a time"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
29
.claude/hooks/inject-sprint-context.sh
Executable file
29
.claude/hooks/inject-sprint-context.sh
Executable file
@@ -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 <<EOF
|
||||||
|
## Sprint context (re-injected after compaction)
|
||||||
|
|
||||||
|
**Branch:** $branch
|
||||||
|
|
||||||
|
**Last 10 commits:**
|
||||||
|
$recent
|
||||||
|
|
||||||
|
**Top of BACKLOG.md:**
|
||||||
|
$backlog_excerpt
|
||||||
|
|
||||||
|
Reminder: re-read /CLAUDE.md if any zero-compromise principle is unclear. /dev-docs/SCHEMA.md is authoritative for table structure.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exit 0
|
||||||
29
.claude/hooks/post-edit-eslint.sh
Executable file
29
.claude/hooks/post-edit-eslint.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# Strip leading absolute prefix if present, so we match repo-relative paths.
|
||||||
|
rel="$path"
|
||||||
|
if [[ "$path" = /* ]]; then
|
||||||
|
rel="${path#$CLAUDE_PROJECT_DIR/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Match apps/app/** or apps/portal/** for .vue/.ts/.tsx/.js files.
|
||||||
|
if ! echo "$rel" | grep -Eq '^apps/(app|portal)/.+\.(vue|ts|tsx|js)$'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract SPA dir and the path relative to it.
|
||||||
|
spa="$(echo "$rel" | grep -oE '^apps/(app|portal)')"
|
||||||
|
inside="${rel#$spa/}"
|
||||||
|
|
||||||
|
# SPA may not exist yet (apps/portal/ is planned but not present in tree).
|
||||||
|
[ -d "$CLAUDE_PROJECT_DIR/$spa" ] || exit 0
|
||||||
|
|
||||||
|
cd "$CLAUDE_PROJECT_DIR/$spa" 2>/dev/null || exit 0
|
||||||
|
pnpm eslint --fix "$inside" >/dev/null 2>&1 || true
|
||||||
|
exit 0
|
||||||
17
.claude/hooks/post-edit-pint.sh
Executable file
17
.claude/hooks/post-edit-pint.sh
Executable file
@@ -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
|
||||||
49
.claude/hooks/protect-files.sh
Executable file
49
.claude/hooks/protect-files.sh
Executable file
@@ -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
|
||||||
50
.claude/settings.json
Normal file
50
.claude/settings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Crewli — Claude Code Instructions
|
# Crewli — Claude Code Instructions
|
||||||
|
|
||||||
|
> See `dev-docs/CLAUDE_CODE_TOOLING.md` for the deterministic guard-rail layer (hooks, subagent, slash commands).
|
||||||
|
|
||||||
## Project context
|
## Project context
|
||||||
|
|
||||||
Crewli is a multi-tenant SaaS platform for event and festival management.
|
Crewli is a multi-tenant SaaS platform for event and festival management.
|
||||||
|
|||||||
192
dev-docs/CLAUDE_CODE_TOOLING.md
Normal file
192
dev-docs/CLAUDE_CODE_TOOLING.md
Normal file
@@ -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 <Model>
|
||||||
|
│ └── 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 <Model>` | Reads model + migration + policy + tests for `<Model>` 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/<name>.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.
|
||||||
Reference in New Issue
Block a user