Refuses the tool calls Grimoire's CLAUDE.md forbids: unmodelled subagents, hand-applied migrations, suffixed co-author trailers, unasked pushes.

<img src="public/brand/logo-light.webp" alt="Dungeon Grimoire" width="320" />
<h1 align="center">Dungeon Grimoire</h1>
Full-stack D&D 5e campaign management for Dungeon Masters and their players. <a href="https://dungeongrimoire.com"><strong>dungeongrimoire.com →</strong></a>
<img src="https://img.shields.io/badge/Vue-3.5-42b883?logo=vue.js&logoColor=white" alt="Vue 3" /> <img src="https://img.shields.io/badge/TypeScript-6.0-3178c6?logo=typescript&logoColor=white" alt="TypeScript" /> <img src="https://img.shields.io/badge/Supabase-PostgreSQL-3ecf8e?logo=supabase&logoColor=white" alt="Supabase" /> <img src="https://img.shields.io/badge/Tailwind-v4-38bdf8?logo=tailwindcss&logoColor=white" alt="Tailwind v4" /> <img src="https://img.shields.io/badge/license-Source%20Available-orange" alt="Source Available" />
Dungeon Grimoire is a unified campaign management platform for D&D 5e. The DM gets a rich authoring and live-play toolkit; players get a separate, role-appropriate portal with real-time data sync. It covers the entire campaign lifecycle — from world-building through live combat — in a single app.
This repository is public for transparency. See Licensing for usage terms.
| Area | What's included |
|---|---|
| Campaign hub | Dashboard, session notes (rich text, AI image gen), pinned quests, party presence |
| World-building | Hierarchical Atlas (17 location types), kanban Quest Log, Factions with directional relations |
| NPCs | Full character sheets, force-directed Relationship Web, AI NPC Generator, stat block with bestiary import |
| Party & Characters | Initiative + HP tracker, full D&D 5e character sheet (Wild Shape, level-up wizard), Hall of Heroes |
| Combat | Bestiary with 12 SRD presets + Open5e sync, Encounter Builder (factions, boss mechanics, pre-scripted events), live Encounter Runner |
| Items & Spells | Item Vault with dual-image ID system, Spellbook with Spell Level Advisor, Workshop crafting recipes |
| Dungeon Building | Features, Traps (CR Advisor), Puzzles with hint reveals, Roll Tables, Loot Tables with claimable drops |
| Publishing | Scriptorium document editor (PDF export, PHB themes), Card Forge (MTG + Tarot print sheets), The Mint (VTT tokens + coins), Illuminator (canvas image processing) |
| DM Screen | Reliquary — quick reference, SRD compendium, custom Tracker builder for homebrew mechanics |
| Soundboard | Ambient scenes + music playlists, Spotify integration |
| Calendar | Custom world calendar — define your own months, intercalary days, and year length; month grid + Chronicle timeline view; ships with 9 built-in D&D settings (Faerûn, Eberron, Greyhawk, Dragonlance, Ravenloft, Planescape, Spelljammer, Dark Sun, Mystara) plus a fully custom option |
Players join any campaign for free via an invite link and get their own portal:
| Layer | Technology |
|---|---|
| Frontend | Vue 3 + TypeScript + Vite (Rolldown) |
| Styling | Tailwind CSS v4 — config in CSS via @theme, no tailwind.config.js |
| Components | Reka UI (shadcn-vue style, headless) |
| State | Pinia (UI) + TanStack Query (server/async) |
| Router | Vue Router 5 |
| Backend | Supabase — PostgreSQL + Auth + Realtime + Storage + Edge Functions |
| Linting | oxlint |
Architecture diagrams (system context, internal layers, third-party integrations, release pipeline) live in context/architecture/.
| Plan | Who | What |
|---|---|---|
| Free DM | Dungeon Masters | 1 campaign, 10 NPCs, 3 custom monsters, 5 encounters, 3 Scriptorium docs, 10 notes, 20 sounds, 1 soundboard page, 3 playlists |
| Pro DM | Dungeon Masters | Unlimited content + 1,500 AI credits/month (monthly allowance, resets each cycle) |
| Player | Anyone | Always free — join any campaign with an invite link |
AI credits are also sold as permanent packs that never expire — purchased credits act as an overage buffer on top of the monthly Pro allowance. Spends draw from the expiring monthly bucket first.
Full pricing at dungeongrimoire.com/pricing.
⚠️ Self-hosting requires a license. See Licensing before proceeding.
# Prerequisites: Node 24+, Docker, and the Supabase CLI
npm install
npm run db:start # boot local Postgres, Auth, Storage, and Studio
npm run dev # dev server against local Supabase
npm run build # production build (vue-tsc + vite build)
npm run lint # oxlint
npm test # vitest
Hosted development is deliberately explicit. Copy .env.example to .env.local, fill in the hosted project values, and run:
npm run dev:hosted
Remote migrations are applied by CI after changes reach main; do not push the working tree's pending migrations manually.
Day-to-day development should run against a local Supabase stack, not the production database. The local stack is a full, throwaway copy of the schema (all migrations replayed) that you can reset freely.
Prerequisites: Docker running, the Supabase CLI installed, and the project linked once (supabase link --project-ref <ref>).
npm run db:start # boot the local stack (Postgres, Auth, Storage, Studio…)
npm run db:status # print local URLs + keys (Studio at <http://127.0.0.1:54323>)
npm run dev # run the app against the local stack (vite --mode localdb)
npm run dev:local # explicit alias for the same local mode
npm run dev:hosted # opt in to the hosted credentials from .env.local
npm run db:reset # wipe + replay all migrations (+ seed.sql if present)
npm run db:stop # tear the stack down
npm run dev and npm run dev:local use the isolated config/env/localdb/.env file (committed; the CLI's universal local defaults — safe, not secrets). The separate environment directory prevents Vite from also loading the gitignored hosted values in .env.local. Only npm run dev:hosted opts into the hosted project.
Seed it with a copy of the remote (tester accounts + app data — no production customer data exists pre-launch):
npm run db:pull # dumps remote auth+public data → supabase/seed.sql (gitignored)
npm run db:reset # rebuilds the local DB and loads seed.sql
The dump is anonymized before it lands (#652). db:pull chains scripts/anonymize-seed.ts, which rewrites every email address in seed.sql to user-<n>@example.invalid. The reason to pull remote data is its volume and shape, not its identities — and an address copied onto a laptop is outside every control that applies to production: no retention period, and out of reach of account erasure, which cannot follow someone into a local file.
One address is kept so the seeded account is still yours to sign into: your git config user.email. Export SEED_KEEP_EMAILS (comma-separated) if the account you log in with locally is not your git identity. db:reset re-checks the file first and refuses to seed a dump that still holds real addresses — which is what you want if you ever run supabase db dump by hand and bypass the chained step. To fix one up after the fact:
npm run db:anonymize # rewrite in place (idempotent)
npm run db:anonymize -- --check # just report; exit 1 if any real address survives
db:pull needs the remote DB password (the CLI prompts, or set SUPABASE_DB_PASSWORD). It excludes the config/reference tables that migrations already seed — the -x flags in the script are the list; it has grown past what is worth restating here, so read it there. Those tables are populated identically by migrations on both sides, so dumping them too would collide on primary keys at db:reset. If you add a new migration-seeded config table, add it to the -x list. If you already hold a dump taken before a table joined that list, delete its INSERT INTO "public"."<table>" statement or re-pull — otherwise the next db:reset fails on the duplicate key.
plans is the newest entry and the reason the rule matters: it was reference data with no migration behind it, so a fresh database had an empty plan catalogue and the first signup died on user_subscriptions_plan_id_fkey. Seeded by 20260809151956_seed_plan_catalogue.sql and excluded from the dump since. If the auth portion of the dump errors, narrow it to --schema public and create tester accounts locally instead (local signup confirmation emails land in Mailpit at <http://127.0.0.1:54324>).
Why the baseline squash omits the
storageschema: a fullsupabase db dumpcaptures the service-managedstorageschema, which a localsupabase startcannot replay (the migration role can't create in / own the storage schema — that schema is provided by the storage service). The baseline keeps only the app'sstorage.objectsRLS policies; the schema itself comes from the service. This keeps local replay green and leaves the remote untouched.
src/
├── assets/ # Tailwind v4 @theme tokens + global CSS
├── ai/ # AI generation composables + provider adapters
├── calendars/ # Calendar adapter pattern (registry + adapters)
├── cartographer/ # Tile-pack authoring engine
├── components/ # Feature components (npcs/, monsters/, encounters/, …)
├── composables/ # TanStack Query hooks, in per-domain subfolders (a few UI/platform primitives stay at the root)
├── data/ # Static data tables (no logic)
├── directives/ # Vue directives (tooltip, roll mode, …)
├── layouts/ # DefaultLayout (DM), PlayerLayout, AuthLayout
├── levelup/ # Level-up wizard
├── lib/ # Cross-cutting infra + feature subsystems
├── manual/ # In-app user manual (markdown)
├── router/ # Routes + auth guard
├── rules/ # Pure D&D 5e rules computation
├── settings/ # Campaign-setting definitions
├── stores/ # Pinia: auth, campaign, ui, encounterRun, …
├── types/ # TypeScript types per feature domain
└── views/ # Page views per feature area
supabase/
├── functions/ # Deno Edge Functions (AI, Stripe, storage, email, MCP)
├── migrations/ # Timestamped SQL migrations
├── tests/ # pgTAP suites
└── checks/ # Deploy-gating content integrity checks
context/
├── architecture/ # System diagrams + outage triage (start here)
├── features/ # Per-feature agent-readable documentation
└── compliance/ # AI Act register, provenance, retention
The source code is published for transparency and community trust — not as open source.
You may: read and study the code.
You may not (without written permission): run, deploy, self-host, modify, distribute, or build products based on this codebase.
Two ways to use Dungeon Grimoire legitimately:
See LICENSE for the full terms.
Contributions are welcome! Bug fixes, new features, and improvements can all be submitted as pull requests. For larger changes it's worth opening an issue first so we can align before you invest the time.
By submitting a pull request you agree that your contribution is assigned to Crocode B.V. and may be used, modified, and relicensed by Crocode B.V. under any terms. You retain the right to be credited as a contributor.
Built by <a href="https://crocode.nl">Crocode B.V.</a> · <a href="https://dungeongrimoire.com">dungeongrimoire.com</a>
hooks/register.ts 109 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Prompt } from '../types'
5
6// Each guard enforces a rule from CLAUDE.md that was written down because an
7// agent kept breaking it. A refusal reaches the model as the tool's error, so
8// every reason says what to do instead, not only what was wrong.
9
10// The person's own last prompt: a push goes through only when it says "push".
11const lastPrompt = atom({ plugin: 'grimoire-guards', key: 'lastPrompt' } as const, null as Prompt)
12
13// A command only where one starts: a line's head, or after `;`, `&&`, `||`,
14// `|` or `(`, behind env assignments and a runner. Anywhere else it is text,
15// such as a commit message that mentions the command it guards against.
16/** Match a regex command body at shell-like boundaries; this is a heuristic, not a shell parser. */
17const command = (body: string) =>
18 new RegExp(String.raw`(?:^|[;&|(])\s*(?:\w+=\S*\s+)*(?:(?:npx|bunx|rtk)\s+(?:-y\s+)?)?` + body, 'm')
19
20// `supabase db push` applies every pending migration in the working tree,
21// other sessions' unmerged ones included. Migrations apply from CI on push.
22const DB_PUSH = command(String.raw`supabase\s+(?:-{1,2}\S+\s+)*db\s+push\b`)
23
24// `Co-Authored-By: Claude Opus 5 (1M context) <...>`: git keys a co-author on
25// the whole name, so the suffix forks one model into two contributors.
26const SUFFIXED_TRAILER = /Co-Authored-By:[^\n<]*\([^\n)]*\)\s*</i
27
28const GIT_PUSH = command(String.raw`git\s+(?:-[Cc]\s+\S+\s+)*push\b`)
29const ASKS_TO_PUSH = /\bpush/i
30
31/** Prefix a denial reason with the plugin name. */
32const why = (reason: string) => `grimoire-guards: ${reason}`
33
34/**
35 * Register prompt tracking and tool guards for agents, migrations, commit
36 * trailers, and pushes. Push checks use the last composer/bridge prompt's
37 * text, matched by ASKS_TO_PUSH; other prompt origins leave it unchanged.
38 * Denied calls return a reason instead of invoking the next handler.
39 */
40export const register: Register = on => {
41 on('prompt.submit', async ($, e, next) => {
42 // Only the person's own words count: typed here, or sent from their phone.
43 // A notification, a peer session or another plugin cannot unlock a push.
44 const kind = e.origin?.kind
45 if (kind === 'composer' || kind === 'bridge') {
46 await update($, lastPrompt, () => e.text)
47 }
48
49 return next(e)
50 })
51
52 // The Overseer Pattern: an Agent call without `model` inherits the session's,
53 // so an unannotated Explore under a Fable session is Fable reading 2,000 files.
54 on('tool.call', { tool: 'Agent' }, ($, e, next) =>
55 e.subagent_type === 'fork' || e.model !== undefined
56 ? next(e)
57 : {
58 deny: why(
59 'every Agent call that is not a fork names its model (CLAUDE.md, Overseer Pattern). ' +
60 'Pass model: "sonnet" (haiku for pure lookups; the top model only for an advisor consult).',
61 ),
62 },
63 ).catch(($, e, next) => (next.called ? next(e) : { deny: why('the Agent guard failed.') }))
64
65 // apply_migration stamps its own version, which never matches the local
66 // file's, so `db push` diverges every time.
67 on('tool.call', { tool: /^mcp__.*supabase.*__apply_migration$/ }, () => ({
68 deny: why(
69 'never apply a migration through the MCP. Create it with /new-migration <name>, ' +
70 'write the SQL, and let CI apply it on push.',
71 ),
72 }))
73
74 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75 const command = e.command
76
77 if (DB_PUSH.test(command)) {
78 return {
79 deny: why(
80 'never run `supabase db push` by hand: it applies every pending migration in the ' +
81 "working tree, other sessions' unmerged ones included. Migrations apply from CI on push.",
82 ),
83 }
84 }
85
86 if (/\bgit\b[^\n]*\bcommit\b/.test(command) && SUFFIXED_TRAILER.test(command)) {
87 return {
88 deny: why(
89 'the Co-Authored-By trailer takes the model name only, no parenthetical ' +
90 '(`Claude Opus 5`, not `Claude Opus 5 (1M context)`). Drop the suffix and commit again.',
91 ),
92 }
93 }
94
95 if (GIT_PUSH.test(command)) {
96 const prompt = await read($, lastPrompt)
97 if (prompt === null || !ASKS_TO_PUSH.test(prompt)) {
98 return {
99 deny: why(
100 'never push unasked. Ask the user first; once their reply says "push", the push goes through.',
101 ),
102 }
103 }
104 }
105
106 return next(e)
107 }).catch(($, e, next) => (next.called ? next(e) : { deny: why('the Bash guard failed.') }))
108}
109types/index.d.ts 8 lines1export type Prompt = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'grimoire-guards': { lastPrompt: Prompt }
6 }
7}
8