Every example below is running code in a production charter — a Laravel API with a
TypeScript/React front end, Docker for local development, Jira and GitHub for state.
Nothing here depends on that stack. Swap Laravel for Rails or Django, make for
just or npm run, Jira for Linear: the patterns are about
where a constraint is enforced, not what enforces it. They ascend deliberately —
rung 1 is a rule and a deny-list entry; rung 5 is an orchestrator that measures
its own cost and writes new rules back into the charter it runs under. Each rung buys
something the one below it could not.
make = your task runner · PROJ-000 = your issue key
· app/Services/Billing/ = your highest-risk module
The cheapest guardrail there is: prose that states the intent, a deny list that makes it non-negotiable, and a sanctioned path that makes compliance the easy option. No scripts, no hooks — three artifacts pointing the same direction.
This project runs entirely inside Docker. An agent that types php artisan migrate
executes it on the host, where there is no PHP extension set and no database — and then,
being helpful, invents a workaround. The failure is not that the command was wrong; it is
that the wrong command was available.
paths: **)authored**Always use `make` targets.** Never run `npm`, `npx`, `php`, `php artisan`,
`composer`, `vite`, or similar tools directly — they execute on the host, not
in the container, and bypass the project's environment.
- No appropriate target exists → add one to the `Makefile` and document it in
`.claude/features/common-commands.md`.
- An existing target is not flexible enough → add `ARGS=` support rather than
reaching for the raw tool.
- Coreutils (`ls`, `grep`, `tail`, `git`, etc.) are fine to run directly.
"permissions": {
"allow": [
"Bash(make *)", "Bash(git *)", "Bash(gh *)", "Bash(jira *)",
"Bash(docker compose exec *)", "Bash(grep *)", "Bash(find *)"
],
"deny": [
"Bash(php *)", "Bash(php artisan *)",
"Bash(npm *)", "Bash(npx *)",
"Bash(composer *)", "Bash(./vendor/bin/*)"
]
}
test-parallel: ## Run use-case-tier tests in parallel via paratest (no DB)
test-adapter: ## Run adapter-tier tests against SQLite :memory: / stubbed HTTP
pint-fix: ## Fix PHP code style (ARGS= for extra flags)
migrate: ## Run database migrations (ARGS= for extra flags)
toolchain.md auto-loads — its paths: glob is **, so it is in context every turn.
php artisan migrate. The deny pattern matches; the call never runs.
make migrate — not an invented workaround.
The rule alone is advisory: a sufficiently stuck agent reasons its way past prose ("just this once, to check the version"). The deny list alone is a wall with no door — blocked, no stated alternative, and the agent starts guessing at paths.
A guardrail has three parts, and skipping any one of them costs you. State the intent so the agent can generalise to cases you did not enumerate. Enforce it mechanically so the constraint does not depend on the agent's mood. Leave a sanctioned path so compliance is cheaper than evasion. Guardrails that only block produce creative circumvention.
A deny list matches command strings. It cannot see inside a file write. To enforce a content rule you need a hook that reads the tool payload — and, crucially, one that inspects only the new content, so the gate judges the change rather than the codebase.
locked: true
hook PreToolUse · Edit|Write|MultiEdit
script scripts/charter/check-banned-tokens.sh
The rule's frontmatter carries the incident that motivated it. That why:
field is not documentation courtesy — it is what lets an agent reason about the edge case
the rule's letter does not cover.
---
description: Functions and patterns that must never appear in committed code —
debug output, raw queries, imperative array functions, lazy-loaded relationships
paths:
- "app/**/*.php"
- "database/**/*.php"
- "tests/**/*.php"
- "routes/**/*.php"
layer: project
locked: true
why: A `dd()` shipped to production swallowed a KeyboardInterrupt and broke the
dashboard; `DB::raw()` with string interpolation is a SQL-injection class; an
N+1 in a hot path silently slows production until it pages someone.
---
# Extract NEW content from tool input — the gate judges the change,
# not the file it lands in.
case "$tool" in
Write) content=$(jq -r '.tool_input.content') ;;
Edit) content=$(jq -r '.tool_input.new_string') ;;
MultiEdit) content=$(jq -r '[.tool_input.edits[]?.new_string]|join("\n")') ;;
esac
declare -a banned=(
'\bdd\s*\(' '\bdump\s*\(' '\bddd\s*\('
'DB::raw\s*\(' 'DB::statement\s*\(' '\bprint_r\s*\('
'\bvar_dump\s*\(' '\bray\s*\('
)
while IFS= read -r line; do
case "$line" in *"charter:allow"*) continue ;; esac
...
done <<< "$content"
if [ -n "$violation" ]; then
cat >&2 <<EOF
charter: blocked $tool on $rel — banned token in new content.
$violation
See .claude/rules/prohibited-patterns.md. Use the structured logger
(\`Log::debug(...)\`) for debug output and Eloquent or parameter-bound
queries instead of raw DB calls. To override one line, append the
inline marker: // charter:allow ray
EOF
exit 2
fi
scripts/, vendor/, database/migrations/) → exit 0. A sensor that blocks on cases it does not understand is a sensor that gets turned off.
// charter:allow and whole-run CHARTER_BYPASS=1. One legitimate exception should not require disabling the gate globally — that is how gates die.
prohibited-patterns.md. Sensor and guide stay coupled, so fixing one prompts fixing the other.
The rule fires only when the file's path happens to match a loaded glob and the agent
happens to weight it. A dd() inserted while debugging survives to review —
and a one-line debug statement in a large diff is precisely what review skims past.
This is the compilation step: a rule that can be expressed as a regex over new content
should be both prose and a hook. Prose so the agent generalises; a hook so
compliance does not depend on generalisation. The test for whether a rule is ready to
compile is falsifiability — "no env() outside config/" compiles;
"prefer clean code" never will.
commits.md is one rule file with two iron laws: no AI attribution, and no
commit with failing checks. It is enforced at four different points in the session
lifecycle, because each failure is cheapest to catch at a different moment — and one of
those sensors exists purely to detect that the others are missing.
session-start.sh prints branch, last commit, dirty count — then checks
that the git pre-commit hook is actually installed and executable.
A missing hook is not an inconvenience; it is the silent absence of
verification, so it warns loudly.
check-commit-msg.sh intercepts git commit -m and tests the
subject against Conventional Commits with an optional [PROJ-123] prefix.
Blocks before the commit object exists — nothing to amend, nothing to force-push.
Stop hook catches the agent declaring "done" with uncommitted code
changes and runs make pre-commit before the turn can close.
make pre-commit, which calls detect-scope.sh
to size the run and make charter-gates to run the diff sensors.
# The git hook is the ONLY gate on the commit path (commits.md) — the agent no
# longer runs `make pre-commit` explicitly. So a missing hook is not a small
# inconvenience, it is silent absence of verification. Warn loudly.
hooks_path="$(git config core.hooksPath || true)"
[ -z "$hooks_path" ] && hooks_path="$(git rev-parse --git-common-dir)/hooks"
if [ ! -x "${hooks_path}/pre-commit" ]; then
echo "charter: ⚠ NO pre-commit hook at ${hooks_path}/pre-commit —
commits are UNVERIFIED. Run \`make install-hooks\`."
fi
# Only intercept actual `git commit -m ...` invocations. Other commands
# (gh pr create, jira comment) may contain the literal string "git commit"
# in a body/heredoc — those must pass.
if ! echo "$cmd" | grep -qE '(^|[;&|[:space:]])git[[:space:]]+commit[[:space:]]'; then
exit 0
fi
# Couldn't parse the message → fail open so unusual invocations pass.
[ -z "$msg" ] && exit 0
re='^(\[[A-Z]+-[0-9]+\] )?(feat|fix|refactor|test|docs|chore|perf|build|ci|style|revert)(\([a-z0-9._-]+\))?!?: .+$'
if [[ ! $subject =~ $re ]]; then
charter: blocked git commit — subject does not match Conventional Commits.
got: $subject
Expected: type(scope): subject
[PROJ-123] type(scope): subject
exit 2
fi
case "$f" in
*.md|*.txt|*.rst|LICENSE|LICENSE.*) ;;
# Generated by `make charter-index` and read by no checker, so `all`
# would verify nothing a docs scope doesn't. Shell scripts deliberately
# do NOT belong here: `make charter-gates` executes scripts/charter/*.sh
# on every pre-commit, so a broken gate script silently stops gating.
.claude/INDEX.json) ;;
*.php) has_php=true ;;
*.ts|*.tsx|*.js|*.jsx|*.css) has_js=true ;;
*) has_other=true ;;
esac
Collapse these to a single commit-time gate and you get the expensive failure mode: a
malformed subject line discovered after a six-minute full-suite run, or worse, a "done"
declared on a red tree because nothing checked at the moment the claim was made. The
SessionStart warning is the easiest of the four to skip building, because
it blocks nothing — and it is the one that catches a fresh clone where
make install-hooks never ran and every other sensor on this list is
silently absent.
Enforcement moment is a design variable, not an implementation detail. Ask, for each law: when is this failure cheapest to fix? Then place the sensor there — and place one more sensor where you would discover that the first is gone. Note the asymmetry in the parsing: strict about what it rejects, generous about what it declines to judge. Unparseable input exits 0. Gates that guess block real work, and gates that block real work get bypassed.
Every codebase has one module where mistakes are not recoverable — the billing calculator, the permissions resolver, the migration runner. Protecting it needs two kinds of sensor that fail in opposite directions: a script that counts, and an agent that understands. The charter deliberately pairs them.
locked: true
agent billing-risk.md
agent scope-guard.md
gate check-regression-test.sh
gate check-diff-size.sh
The rule's why: and its "recent regression context" section do work no regex
can: they tell the agent which kind of caution applies here. Not "be careful" —
specifically, pin behaviour before touching it, and never bundle a behaviour change with
a refactor. Substitute your own module and your own incident; the shape holds.
---
description: The billing calculator is mission-critical and recently regressed
— changes require regression tests, explicit approval for behavior changes,
and characterization tests before any refactor
paths:
- "app/Services/Billing/**"
- "tests/**/Billing/**"
layer: project
locked: true
why: Billing regressions ship wrong money to the wrong accounts; the module
already broke once in production this year and changes here have
non-recoverable blast radius.
---
## IRON LAWS
**EVERY CHANGE PAIRED WITH A REGRESSION TEST.**
No change under `app/Services/Billing/` lands without a test under
`tests/UseCase/Services/Billing/` that fails *before* the change and passes
after. Pure refactors need characterization tests that pin current behavior.
**BEHAVIOR CHANGES REQUIRE EXPLICIT USER APPROVAL.**
Any diff that intentionally changes a calculator's output for a given input is
a behavior change — even if the new output is "more correct".
Do not bundle a behavior change with a refactor; separate commits, separate PRs.
## Recent regression context
The last production regression here turned on a subtle date/rounding
combination the suite did not pin. Until the characterization cards land,
assume every calculator branch is undertested.
check-regression-test.sh — app code changed, no test file added or
modified → exit 2. It cannot tell a good test from a bad one. It does not
need to; it catches the case where there is none.
billing-risk.md — a read-only agent with allowed_tools
restricted to Read, Grep, Glob and three
git verbs. It reads the diff and asks whether the test actually pins the
behaviour, and whether the math precision quietly shifted.
check-diff-size.sh — warns past 15 files, blocks past 40. Pure counting;
no idea what the card asked for.
scope-guard.md — reads the Jira card and the diff together, and flags
drive-by edits that are individually reasonable and collectively out of scope.
# Blast-radius gate. A "small fix" that quietly rewrote 40 files is a signal to
# stop and look. Mechanical backstop to the intent-aware
# .claude/agents/scope-guard.md.
warn="${CHARTER_DIFF_WARN:-15}"
max="${CHARTER_DIFF_MAX:-40}"
if [ "$count" -gt "$max" ]; then
charter: blast-radius gate — diff touches $count files (hard ceiling $max).
A bounded change rarely rewrites this many files. Stop and look: is this
scope creep? Split it into separate cards, or — if the size is intentional
(a sweep, a rename) — re-run with CHARTER_BYPASS=1.
See .claude/agents/scope-guard.md for the intent-aware companion check.
fi
if [ "$count" -gt "$warn" ]; then
echo "charter: blast-radius warning — diff touches $count files
(soft limit $warn). Confirm it is all in scope for the card." >&2
fi
Gates alone: a change adds one trivial assertion, passes the regression gate, and ships a rounding-mode shift. Agents alone: expensive, non-deterministic, and the first thing dropped when a run is under time pressure. The regression that motivated this rule turned on a date-and-rounding edge case — it would have passed a file-count check and a test-presence check both.
Sensors have a cost/precision curve, and high-risk code needs both ends of it. Scripts are free, deterministic and dumb — run them always. Agents are expensive, probabilistic and can read intent — run them where the blast radius justifies the cost. The implementation detail that makes the pair work: each one names the other in its output, so a human who sees the cheap signal knows the expensive check exists.
Everything above composes into one delivery run: an orchestrator derives the next phase from external state, dispatches it to a subagent, verifies from the store rather than the report, prices the run, and proposes a new charter rule. Then the interesting part — the machinery that stops that flywheel from becoming a ratchet.
/drive reads Jira, GitHub and git — never conversation memory — and
derives the next phase from a table. Its iron law is DERIVE, DON'T REMEMBER.
single-writer.md prohibits
concurrent writers outright — no gate unlocks it, no urgency justifies
it. The child gets identifiers (Jira key, PR number, worktree path), never a
briefing; if a phase can only be dispatched by explaining the situation to it, the
loop has drifted into holding state in conversation and must stop.
/implement, four review agents run in parallel — legal,
because they are read-only sensors returning structured findings against a shared
schema. Reconciliation is dedup-and-filter, not a forensic merge. Only the main loop
applies fixes.
make drive-metrics KEY=PROJ-482 — merged LOC against token cost, priced
from a rate table. Reported without editorialising, and with its two limits stated:
list-price arithmetic, not an invoice; LOC flatters a card that wrote a lot.
/learn, invoked with no argument, runs the retrospective itself from the
run's recorded artifacts, proposes one rule diff, and stops for human approval.
Derive candidates from what the run *recorded*, never from recall:
1. A two-strike gate — the same failure twice.
2. A repeated `auto-resolved:` judgment call across checkpoint lines.
3. A review finding re-raised after a fix.
4. A workflow gap.
5. Cost — but expensive is not the same as wasteful, and only waste is a lesson.
Then apply the bar, and expect to fail it:
A candidate earns a charter change only if it recurred within the run,
or names a rule that was already wrong. A one-off call that went fine is
not a lesson — promoting it is exactly how a charter accumulates the
decorative rules `/prune` later has to retire.
"No lessons — nothing recurred" is a correct and common outcome.
Say it in one line and stop. Do not proceed to Step 1 to have something
to show for the invocation.
A flywheel that only adds rules is a ratchet, and the cost is paid on every file the agent
touches. So the charter is instrumented against itself. make charter-index
builds a descriptor index; make charter-which replays glob matching for any
path and totals the bytes of charter the agent reads before it reads the file.
Run against one calculator file, on a charter of the size given at the top of this page:
| Kind | Id | Matching glob | Bytes |
|---|---|---|---|
| rule | services | app/Services/** | 12,944 |
| rule | security | app/**/*.php | 9,669 |
| rule | commits | ** | 9,283 |
| feature | billing-flow | app/Services/Billing/** | 6,416 |
| rule | single-writer | ** | 5,651 |
| rule | feature-flags | app/**/*.php | 5,407 |
| rule | prohibited-patterns | app/**/*.php | 4,668 |
| rule | design-principles | app/**/*.php | 4,201 |
| rule | no-facades | app/**/*.php | 3,743 |
| rule | billing-high-risk | app/Services/Billing/** | 3,431 |
| rule | verification-before-completion | ** | 3,207 |
| rule | git-workflow | ** | 2,351 |
| rule | env-config | app/**/*.php | 2,058 |
| rule | toolchain | ** | 1,428 |
| rule | role-checks | app/**/*.php | 1,373 |
| TOTAL — over budget (60,000) across 15 charter files | 75,830 | ||
Read the shape rather than the totals. Three rules load on ** and
are paid on every file in the repo. Five more load on app/**/*.php, which is
most of the backend. Only two are actually specific to this module — and they are the two
smallest entries in the table. That distribution is the finding: the budget is consumed
almost entirely by rules that are not about the file being edited.
OVER load budget: 75830 / 60000 bytes across 15 charter files
→ merge into the rule that already owns this glob, or move reference
material to .claude/features/ with narrower paths:
(.claude/rules/charter-versioning.md)
Two read-only scans complete the brake. rule-drift.sh checks every
paths: glob against the repo and flags globs matching zero files — a rule
describing code that no longer exists. decorative-rule-check.sh counts each
rule three ways (commits in 90 days, cross-references from skills and agents, mentions in
CLAUDE.md/CHARTER.md) and marks the ones scoring zero everywhere
with a headstone. Both are wired into /prune, the deliberate counterpart to
/learn.
Run the flywheel for six months without a brake and the charter reaches the state every long-lived one does: rules that contradict each other, rules whose globs point at deleted directories, and a per-file load so large the agent's attention is spent reading policy instead of code. The failure is invisible from inside — nothing errors. Quality just quietly degrades, and the natural response is to write another rule.
The closed loop is the point, but the brake is the lesson. A charter is a codebase: it
accumulates, it rots, and it needs a deletion path with the same status as its addition
path. /learn and /prune are deliberately symmetric, both
approval-gated, both human-owned. And the load budget makes the abstract cost of
"one more rule" into a number attached to a specific file — which is the only form of
cost anyone actually acts on.