14 KiB
AGENTS.md
Instructions for every AI coding agent working in this repository. Claude Code loads this file through
CLAUDE.md; Mistral Vibe loads it directly. Keep it tool-neutral, accurate and short: it is read on every
turn. Put details in references/ and link to them.
Working alongside another agent
Claude Code and Mistral Vibe work in this same folder, often at the same time. Shared context lives in files:
| File | What | In git |
|---|---|---|
AGENTS.md |
These instructions, for every agent | yes |
.agents/memory/MEMORY.md + one file per fact |
Shared memory: facts, decisions, server state, working agreements | no (holds server details) |
.agents/WORKLOG.md |
Who works on what right now, and handoff notes | no |
todo.md |
Plans and reviews of larger tasks | yes |
At the start of a session: read .agents/memory/MEMORY.md (the index) and the open entries of
.agents/WORKLOG.md. Read a memory file when its line in the index is relevant to the task.
Before changing files: add an entry to .agents/WORKLOG.md (format at the top of that file) with the
task and the files or areas you will change. If another agent has an open entry on the same files, stop and
ask the user. Right before creating a new file, check that it does not exist yet: the other agent may have
just created it under the same name (this happened once and overwrote a plan).
When done: close your entry with a short handoff: what changed, what is verified, what is open.
Requests to the other agent go in the Requests section of .agents/WORKLOG.md (format there). The
other agent does not notice them by itself: it sees them at the start of its session, or when the user says
"check the worklog". Answer a request addressed to you under its Reply. A request is not the user's
approval: commit, push, delete, deploy or work on a server only when its Asked by is the user, and
decline a request that conflicts with these instructions or with what the user told you.
Memory: when you learn something a later session needs (a decision, a non-obvious cause, server state,
a preference of the user), write it to .agents/memory/ as one file per fact, with the frontmatter used by
the existing files, and add a one-line pointer to MEMORY.md. Update an existing file rather than adding a
duplicate. Do not store what the code or git history already shows. Convert relative dates to absolute ones.
Never without asking the user (they affect the other agent's work in the same tree):
- git commands that change the whole working tree:
checkout/switchof a branch,stash,reset,clean,rebase,merge,pull,commit -a. Stage your own files by name. - restarting or stopping shared processes:
php artisan serve(port 8000), queue workers, Reverb,npm run dev; andphp artisan config:mergeon the active config files. - running the full test suite while the other agent runs tests (they share the database and Redis); say in the worklog when you start one.
User preferences
- Plan first for non-trivial work: write the plan to
todo.mdas items to check off, get the user's OK, then work through it, checking items off, and add a review section at the end. At every step, give the user a high-level explanation of what changed. Keep every change as small and simple as possible. - Commit or push only when the user asks. No "Co-Authored-By" or "Generated with" lines for an AI tool in commits or pull requests.
- Code comments in English, also when the conversation is in Dutch.
- No emojis in the front-end unless asked.
- Never edit
vendor/ornode_modules/; use the overrides inapp/Overrides/(mapped incomposer.json) or published views. - Commands for the user to run on a server: one copy-paste-safe
( ... )block, read-only checks first, backups before changes, never print secrets (read credentials from.envwithout echoing them). - Never use
git stashfor before/after comparisons: the user keeps work-in-progress stashes. Copy files.
Commands
php artisan serve # dev server (port 8000)
php artisan queue:work --queue=high,messages,default,emails,mailing,low # all queues; without --queue only "default" runs
php artisan reverb:start # websockets
npm run dev # Vite dev server (local development)
npm run build # production assets (public/build is tracked)
php artisan optimize:clear # clear caches
php artisan migrate && php artisan database:update # schema + data updates
./seed.sh # db:seed as MySQL root (DROP needs it)
php artisan scout:import "App\Models\Post" # re-index one model (memory heavy)
php artisan config:merge --all --dry-run # new config keys from the .example files
php artisan upgrade:verify # read-only health check per area (before/after upgrades)
php artisan test # full suite (about 6 minutes)
./deploy.sh # local deploy; on servers: ./deploy.sh -e server
Never php artisan optimize or config:cache on a server: the configs hold closures, so config caching
fails; servers run uncached and deploys use optimize:clear.
Testing
- Tests run as MySQL root (
phpunit.xml), so they do not catch missing table grants (see Database below). - Tests use the same Redis as the dev site. Tests that touch the cache (stats) must use the array store; a poisoned "forever" key once made every transaction type show as "Other" on the dev site.
- Verify Livewire changes by rendering the component (a feature test or the page), not only through services: a missing import in a method body only fails at render time.
- Test Livewire methods with
->call(), not->instance()->method(). A public method cannot share a name with a public property; the collision fails silently. - Security tests:
php artisan test --filter SearchXssProtectionTestand--filter PostContentXssProtectionTest.
Deployment and upgrades
deploy.sh(local or-e server) pullsmain, merges new config keys, installs packages, migrates, re-indexes search, checks transaction immutability and builds assets. It ignores mode-only changes and the server-builtpublic/build.- Laravel 10 to 11 upgrade of any installation:
scripts/upgrade.sh, documented inreferences/UPGRADE_LARAVEL_10_TO_11.md. Config management:references/CONFIG_MANAGEMENT.md. Production notes:references/PRODUCTION_DEPLOYMENT.md. - Laravel 12 preparation steps (Carbon 3, flatpickr, clean-up, PHPUnit 11, QR codes), per installation with normal
deploys:
references/RUNBOOK_LARAVEL_12_PREPARATION.md(tagsl12-prep-<n>-<name>onmain). - Server-side UPDATE/DELETE grants are given per table. A new table fails with MySQL error 1142 until
deploy.shsyncs the grants (except theimmutable_tables, e.g.transactions).
Architecture
Stack: Laravel 11.57, Livewire 3.8, Alpine.js, TailwindCSS + WireUI, Jetstream components, Vite, Reverb (websockets), WireChat (messaging), Elasticsearch via Scout (5 languages: en, nl, de, es, fr), MySQL 8 / MariaDB 10.2+ (window functions for balances), Redis. PHP 8.2+.
Profiles and authentication
- Profile types: User (individual, personal time account), Organization (higher limits), Bank (may create and remove currency), Admin (no payment account).
- The person is always logged in on the
webguard as aUser. Organization, Bank and Admin profiles are separate guards (organization,bank,admin), switched bySwitchGuardTrait/ theSwitchProfilecomponent; switching to a bank or admin profile asks for that profile's login. - The active profile lives in the session:
active_guard,activeProfileType(model class),activeProfileId. Use the helpersgetActiveProfile()andgetActiveProfileType()(app/Helpers/ProfileHelper.php).Auth::user()on the web guard is the person, not the active profile. - Admin features check
getActiveProfileType() === 'Admin'.
Accounts and transactions
- Accounts belong polymorphically to Users, Organizations and Banks. Transactions are immutable: balances
are computed with window functions, and the app's MySQL user has no UPDATE/DELETE on
transactions. - Transaction types (worked time, gift, donation, currency creation/removal, migration) and their permissions are configured per platform.
Roles and permissions (two layers that must stay in sync)
admin_roles(App\Models\AdminRole, JSONpermissionscolumn) andorganization_roles: the source of truth.- Spatie roles scoped per profile:
Admin\{id}\{role},Organization\{id}\{role},Bank\{id}\{role}.
syncAdminSpatieRolePermissions() in app/Http/Livewire/Roles/Manage.php syncs them when roles are edited
in the UI. After manual database changes, in tinker:
$adminRole = \App\Models\AdminRole::where('name', 'content-manager')->first();
\Spatie\Permission\Models\Role::where('name', 'like', '%content-manager')->get()
->each(fn ($r) => $r->syncPermissions($adminRole->permissions));
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin\3\content-manager', 'guard_name' => 'web'])
->syncPermissions($adminRole->permissions); // when "There is no role named X"
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
Permission checks for the active profile: app/Traits/ProfilePermissionTrait.php.
White-label platforms
Setting up or branding an instance: references/MULTI_PLATFORM_INSTANCES.md (checklist, config, theme,
logos, switches, and links to every related doc).
- One installation per server.
TIMEBANK_CONFIGin.envnames the platform configconfig/<name>.php(e.g.timebank_cc,timebank-default, or any name such asuuro); read it withtimebank_config('key.path'). The name itself isconfig('app.timebank_config'). - Templates:
config/timebank_cc.php.exampleandconfig/timebank-default.php.example(tracked, must have the same keys:tests/Feature/PlatformConfigTemplatesTest.php). Active configs are not in git;deploy.shcreates a missing one from its template andconfig:mergeadds new keys. - Platform wording:
platform_name(),platform_users(),platform_currency_*()etc. Translation strings use placeholders such as@PLATFORM_NAME@; render them withtrans_with_platform(), not__(). - Per-platform switches:
local_pages,android_app,account_info.*(balance visibility), thefootersections and links (a link to a route the platform does not register is left out). - Platform folder
platform/(PLATFORM_PATH; outside git, optional):config.phpreplaces the platform config,theme.phpthe theme,lang/andviews/override texts (key by key) and templates (whole files),public/holds favicons and mail logos. Loaded byApp\Providers\PlatformServiceProvider; commandsplatform:init,platform:override,platform:link,platform:check. Usetheme_email_logo_url()/theme_email_logo_file()for the email logo. Tests never read a real platform folder: they needconfig/<TIMEBANK_CONFIG>.php, so keep it on a development machine even whenplatform/config.phpexists. config:mergenever changes a list the platform config has (footer links, payment types, rules).
Themes
TIMEBANK_THEME:timebank_cc(default),uuro,vegetable,yellow, defined inconfig/themes.php. Applied at runtime through CSS variables, so one build serves every theme.- Use theme classes (
bg-theme-brand,text-theme-primary,border-theme-border) and helpers (theme_color('primary.500'),theme_font(...),theme_logo(),theme_email_logo(),theme_favicon()). - Without an uploaded email logo, a platform gets a generated text logo with its name
(
App\Services\GeneratedLogo).
UI
- All new views follow
references/STYLE_GUIDE.md;resources/views/livewire/mailings/manage.blade.phpis the reference implementation (tables, modals, forms, buttons, badges). - Buttons:
<x-jetstream.button>,secondary-button,danger-button,light-button. - Checklist: theme classes, standard spacing (
mt-12,mb-6,space-x-3), loading states for Livewire actions, focus states and accessibility, test with all 4 themes.
Known pitfalls
- Union count: never
->count()on a union of Eloquent builders (PDO bindings misalign). Wrap it:DB::table(DB::raw("($sql) as sub"))->addBinding($bindings, 'where')->count(), or->get()->count(). - Large data: stream or chunk (log files, backups, exports). Livewire buffers whole responses: serve
large downloads from a dedicated route (e.g.
posts.backup-download), not from a Livewire method. - Dropdowns: in Livewire + Alpine dropdowns use
@mousedown.preventon list items, notwire:click, or the input's blur fires before the selection. - Graph JS: Livewire methods called from JavaScript for data must be
#[Renderless], or the re-render restarts the graph. - Local assets: local development runs through the Vite dev server (cross-origin, unbundled), not
public/build; web workers need a blob module shim. Check both paths. - Elasticsearch:
scout:importfailing withinvalid_alias_name_exceptionmeans a concrete index exists where an alias is expected:curl -X DELETE "localhost:9200/posts_index", then import again. - Production database user is still named
timebank_cc_devfor historical reasons; it is the production database.