Merge WS-TOOLING-001: Claude Code deterministic guard-rail layer

This commit is contained in:
2026-05-05 23:58:32 +02:00
13 changed files with 547 additions and 0 deletions

2
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
worktrees/
.DS_Store

View 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.

View 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`.

View 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 510 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.

View 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.

View 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

View 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

View 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
View 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
View 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
View 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"
}
]
}
]
}
}

View File

@@ -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.

View 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` | 510 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.