Chapter 7 of 12 · Guardrails

The agents do not decide when to ask you. A file does.

gates.json is the list of promises you make to the humans on the team. A hook enforces it outside the model. Bellows carries the question to your phone.

Agents Bellows, and every agent the hook watchesFiles .claude/gates.json · settings.json · hooks/You need Chapter 1 done
The idea

Three layers: the file, the hook, the messenger

Ask an agent to be careful and it will be careful when it remembers to be. That is not a guardrail. A guardrail is something the agent cannot forget because it never gets a vote.

The file. .claude/gates.json lists the rules. Each one names a tool and a pattern, a decision (ask or deny) and a reason in plain words. It also lists the escalations Bellows watches for and the budgets no run may exceed. It is short on purpose. Every rule is a promise.

The hook. .claude/hooks/guard.mjs runs before every shell command and file write an agent makes. It reads the call, matches it against the file, and hands Claude Code a decision. The model does not see this happen and cannot skip it.

The messenger. Bellows reads the same file. When a threshold trips or an issue is Ready to Deploy, it posts one message with the reason from the file and waits for Priya's reply in Linear.

A gate is a promise, not a setting

Write each reason for the person who will read it at 7pm on their phone. "Production deploy. Always a human." tells Priya what she is being asked and why in five words. If you cannot write the reason in one sentence, you do not have a gate yet.

The file

gates.json, rule by rule

This is the whole guardrail layer for Alderline. Ten rules, seven escalations, five budgets, one owner. Copy it and change the names.

{
  "$comment": "The human gates. Bellows and the guard hook read this file. A rule that matches turns an agent action into a question for Priya. Decisions: 'ask' stops and prompts; 'deny' blocks with the reason. Keep this file short; every rule is a promise you make to the humans on the team.",
  "owner": { "name": "Priya Raman", "notify": ["slack:#alderline-gates", "sms:+1-503-555-0142"], "quietHours": "22:00-07:00 America/Los_Angeles" },
  "budgets": {
    "maxMinutesPerRun": 45,
    "maxRunsPerIssuePerDay": 6,
    "maxReviewRounds": 2,
    "maxQaFailsPerIssue": 3,
    "maxDailySpendUsd": 40
  },
  "rules": [
    { "id": "prod-deploy", "when": { "tool": "Bash", "command": "vercel .*--prod|vercel promote|npm run deploy" }, "decision": "ask", "reason": "Production deploy. Always a human." },
    { "id": "spend", "when": { "tool": "Bash", "command": "vercel domains add|stripe |npm publish|aws |gcloud " }, "decision": "ask", "reason": "This can cost money or change an account." },
    { "id": "delete-data", "when": { "tool": "Bash", "command": "DROP |TRUNCATE |DELETE FROM|rm -rf|prisma migrate reset|db:reset" }, "decision": "ask", "reason": "Deletes data. Priya confirms the target first." },
    { "id": "force-push", "when": { "tool": "Bash", "command": "git push .*--force|git push -f|git reset --hard origin" }, "decision": "deny", "reason": "History is shared. Open a new commit instead." },
    { "id": "secrets", "when": { "tool": "Edit|Write|MultiEdit", "path": "(^|/)\\.env(\\.|$)|secrets?/|\\.pem$|\\.key$" }, "decision": "deny", "reason": "Secrets never go through an agent. Priya sets them in Vercel." },
    { "id": "billing-code", "when": { "tool": "Edit|Write|MultiEdit", "path": "^src/billing/|^src/lib/payments" }, "decision": "ask", "reason": "Billing code. Warden's threat note and Priya's yes come first." },
    { "id": "auth-code", "when": { "tool": "Edit|Write|MultiEdit", "path": "^src/auth/|middleware\\.ts$|proxy\\.ts$" }, "decision": "ask", "reason": "Authentication and the access gate." },
    { "id": "migrations", "when": { "tool": "Edit|Write|MultiEdit", "path": "^drizzle/|^prisma/migrations/|^migrations/" }, "decision": "ask", "reason": "Schema change. Show the migration and the rollback." },
    { "id": "customer-send", "when": { "tool": "Bash|mcp__gmail__send_message|mcp__resend__*", "command": "resend|sendgrid|mail\\.send|twilio" }, "decision": "ask", "reason": "Anything that reaches a real customer is sent by Priya." },
    { "id": "external-network", "when": { "tool": "Bash", "command": "curl .*(?<!vercel\\.app|localhost|127\\.0\\.0\\.1|api\\.linear\\.app|api\\.github\\.com)[a-z]/" }, "decision": "ask", "reason": "Calling somewhere new. Say where and why." }
  ],
  "escalations": [
    { "id": "review-deadlock", "when": "Flint and Ember disagree after maxReviewRounds", "decision": "ask", "reason": "Two engineers, two opinions. The founder picks." },
    { "id": "qa-third-fail", "when": "Gauge reports THIRD FAIL", "decision": "ask", "reason": "Three cycles usually means the spec is wrong, not the code." },
    { "id": "flaky", "when": "Gauge reports FLAKY", "decision": "ask", "reason": "Reruns hide bugs. A human decides whether to quarantine the test." },
    { "id": "security-high", "when": "Warden reports any High or SECRET FOUND", "decision": "ask", "reason": "Rotate the key, then decide." },
    { "id": "scope-size", "when": "Anvil sizes a slice above 5 points", "decision": "ask", "reason": "Big slices hide risk. Approve the split or the exception." },
    { "id": "customer-data", "when": "Anvil flags customer-data, auth, billing or delete", "decision": "ask", "reason": "Priya sees every change to what customers trust us with." },
    { "id": "budget", "when": "Any budget above is exceeded", "decision": "ask", "reason": "Spend and time are the founder's call." }
  ]
}
IdFires when an agent…DecisionWhy
Rules · matched by the guard hook on every tool call
prod-deployruns a shell command that deploys to production: vercel --prod, vercel promote, npm run deployaskProduction deploy. Always a human.
spendruns something that can cost money or change an account: domains, Stripe, npm publish, aws, gcloudaskThis can cost money or change an account.
delete-dataruns SQL or a shell command that deletes: DROP, TRUNCATE, DELETE FROM, rm -rf, a migration resetaskDeletes data. Priya confirms the target first.
force-pushrewrites shared git history with a force push or a hard reset to origindenyHistory is shared. Open a new commit instead.
secretswrites to a .env file, a secrets/ folder, or a .pem or .key filedenySecrets never go through an agent. Priya sets them in Vercel.
billing-codeedits anything under src/billing/ or src/lib/paymentsaskBilling code. Warden's threat note and Priya's yes come first.
auth-codeedits src/auth/, middleware.ts or proxy.tsaskAuthentication and the access gate.
migrationsedits a schema migration under drizzle/, prisma/migrations/ or migrations/askSchema change. Show the migration and the rollback.
customer-sendcalls anything that reaches a real customer: resend, sendgrid, mail.send, twilio, or a mail MCP toolaskAnything that reaches a real customer is sent by Priya.
external-networkcurls a host that is not the preview, localhost, Linear or GitHubaskCalling somewhere new. Say where and why.
Escalations · matched by Bellows on the status line an agent writes
review-deadlockFlint and Ember still disagree after maxReviewRounds (2)askTwo engineers, two opinions. The founder picks.
qa-third-failGauge writes THIRD FAIL, or COVERAGE: under 100%askThree cycles usually means the spec is wrong, not the code.
flakyGauge writes FLAKY:askReruns hide bugs. A human decides whether to quarantine the test.
security-highWarden writes WARDEN BLOCK or SECRET FOUNDaskRotate the key, then decide.
scope-sizeAnvil sizes a slice above 5 pointsaskBig slices hide risk. Approve the split or the exception.
customer-dataAnvil flags customer-data, auth, billing or deleteaskPriya sees every change to what customers trust us with.
budgetany budget is exceeded: minutes per run, runs per issue per day, daily spendaskSpend and time are the founder's call.

Two decisions only. ask stops and prompts a human. deny blocks and tells the agent why, so it can do the right thing instead. There is no "warn". A warning an agent can read and ignore is not a gate.

Permissions and hooks

settings.json: what agents may do freely, and what is enforced

Claude Code reads this file from .claude/settings.json in the repo. It does two different jobs and it is worth keeping them apart in your head.

{
  "$comment": "Project settings for Claude Code in the Alderline CRM repo. Permissions say what agents may do without asking. Hooks are the enforcement layer: the guard runs before every shell command and file write and turns gate matches into a question for the human; the after-edit hook keeps lint honest; the stop hook reminds the last agent to call Quill. Check the hook JSON shape against the current Claude Code docs before copying.",
  "permissions": {
    "allow": [
      "Read", "Grep", "Glob", "Edit", "Write",
      "Bash(npm run lint)", "Bash(npm run test)", "Bash(npm run test:*)", "Bash(npm run build)", "Bash(npm run dev)",
      "Bash(git status*)", "Bash(git diff*)", "Bash(git log*)", "Bash(git add*)", "Bash(git commit*)", "Bash(git checkout -b*)", "Bash(git push origin ALD-*)",
      "Bash(gh pr create*)", "Bash(gh pr view*)", "Bash(gh pr diff*)",
      "mcp__linear__*", "mcp__notion__*", "mcp__github__*"
    ],
    "deny": [
      "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
      "Bash(git push --force*)", "Bash(git push -f*)",
      "Bash(vercel --prod*)", "Bash(vercel promote*)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "node .claude/hooks/guard.mjs", "timeout": 10 } ] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "node .claude/hooks/after-edit.mjs", "timeout": 60 } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "node .claude/hooks/on-stop.mjs", "timeout": 10 } ] }
    ]
  }
}
Permissions · what agents may do without asking

allow is the list of tools and commands an agent uses freely: read, edit, lint, test, commit, push its own branch, open a PR, talk to Linear and Notion. deny is the short list it can never do, whatever it decides. Everything not listed falls back to Claude Code's default, which asks. Permissions shape the session so the agent is not interrupted for routine work.

Hooks · enforcement that does not depend on judgement

A hook is a program Claude Code runs at a fixed moment: before a tool call, after an edit, when the session ends. The guard runs before every shell command and file write and can turn it into a question or a refusal. The model does not choose whether the hook runs. That is the property you want for a gate.

Belt and braces: vercel --prod appears in both the deny list and the prod-deploy rule. The deny stops a builder in a session. The rule is what Bellows reads when it is time to deploy, and what the guard reports to telemetry. Which layer speaks first when both match is a Claude Code detail. Check the permission rule syntax and the hook JSON shape against the current docs at code.claude.com before you copy this. The shape of the idea does not change.

The hook

guard.mjs, fifty lines that do not argue

This is the enforcement. It is small enough to read in one sitting, and you should, because every rule you add runs through it.

#!/usr/bin/env node
// .claude/hooks/guard.mjs — the PreToolUse gate.
//
// Claude Code runs this before every Bash, Edit, Write and MultiEdit call and
// passes the call as JSON on stdin. We match it against .claude/gates.json.
// A matching rule with decision "ask" makes Claude Code prompt the human
// before the tool runs; "deny" blocks it and tells the agent why. Everything
// else passes through untouched. Every decision is appended to the telemetry
// log so the dashboard can show how often gates fire.
//
// Output shape (check the current Claude Code hooks docs before relying on it):
//   { "hookSpecificOutput": { "hookEventName": "PreToolUse",
//       "permissionDecision": "allow" | "ask" | "deny",
//       "permissionDecisionReason": "..." } }

import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
const gates = JSON.parse(readFileSync(join(here, '..', 'gates.json'), 'utf8'));

let input = '';
for await (const chunk of process.stdin) input += chunk;
const call = input ? JSON.parse(input) : {};
const tool = call.tool_name || '';
const ti = call.tool_input || {};
const command = ti.command || '';
const path = (ti.file_path || ti.path || '').replace(/\\/g, '/').replace(root.replace(/\\/g, '/') + '/', '');

function matches(rule) {
  const w = rule.when || {};
  if (w.tool && !new RegExp('^(' + w.tool + ')$').test(tool)) return false;
  if (w.command && !new RegExp(w.command, 'i').test(command)) return false;
  if (w.path && !new RegExp(w.path).test(path)) return false;
  return Boolean(w.command || w.path);
}

const hit = (gates.rules || []).find(matches);

function log(event) {
  try {
    const dir = join(root, 'telemetry'); mkdirSync(dir, { recursive: true });
    appendFileSync(join(dir, 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), agent: process.env.AGENT_NAME || 'unknown', issue: process.env.ISSUE_ID || null, ...event }) + '\n');
  } catch { /* telemetry must never block work */ }
}

if (!hit) { log({ event: 'tool', tool, gate: null }); process.exit(0); }

log({ event: 'gate', tool, gate: hit.id, decision: hit.decision, detail: command || path });
const reason = `[gate:${hit.id}] ${hit.reason}` + (hit.decision === 'ask' ? ' Say in one sentence what you are about to do and wait for Priya.' : '');
process.stdout.write(JSON.stringify({
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: hit.decision, permissionDecisionReason: reason }
}));
process.exit(0);
  1. Read the call from stdin

    Claude Code pipes the tool call in as JSON: the tool name and its input. For Bash that is the command. For Edit, Write and MultiEdit it is the file path. The hook normalises the path to be relative to the repo so the rules can use short patterns like ^src/auth/.

  2. Match against gates.json

    matches() takes the first rule whose tool pattern fits and whose command or path pattern fits. The tool pattern is anchored, so Bash does not match BashOutput. Command matching is case-insensitive. A rule with neither a command nor a path never matches, so a typo cannot create a rule that fires on everything.

  3. Decide: allow, ask or deny

    No match: the hook logs a tool event and exits without output, which lets the call proceed under the normal permissions. A match: the hook writes permissionDecision from the rule and a reason prefixed with the rule id. For ask it adds one instruction to the agent: say in one sentence what you are about to do and wait for Priya.

  4. Log every decision

    Both branches append one line to telemetry/events.jsonl with the agent name and issue id from the environment Bellows set. The dashboard folds those lines into how often each gate fires, how often it is approved, and how long Priya took. Telemetry never blocks: the write is wrapped in a try so a full disk does not stop the crew.

  5. Exit 0

    The hook always exits cleanly. The decision travels in the JSON, not the exit code. Check the current Claude Code hooks docs for the exact output shape; the comment at the top of the file says where to look.

The stop hook

One more hook, for the end of a run. It logs the end so Bellows can measure time, and it notices when the run changed how the crew works: anything under .claude/, AGENTS.md or CLAUDE.md. When that happens it tells the agent to call Quill before it finishes, so the change becomes a learning-log entry. That is how the dashboard counts learning.

#!/usr/bin/env node
// .claude/hooks/on-stop.mjs — runs when an agent session ends.
//
// Records the end of the run in the telemetry log (Bellows records the start),
// and, if the run changed anything under .claude/ or AGENTS.md or CLAUDE.md,
// flags it as a learning event: the crew changed how it works. Quill turns
// those into learning-log entries in the wiki.

import { appendFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
let input = ''; for await (const c of process.stdin) input += c;

let learned = [];
try {
  const changed = execSync('git status --porcelain -- .claude AGENTS.md CLAUDE.md kit', { cwd: root, stdio: 'pipe' }).toString().trim();
  if (changed) learned = changed.split('\n').map(l => l.slice(3));
} catch { /* not a git repo yet */ }

try {
  mkdirSync(join(root, 'telemetry'), { recursive: true });
  appendFileSync(join(root, 'telemetry', 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), agent: process.env.AGENT_NAME || 'unknown', issue: process.env.ISSUE_ID || null, event: 'end', learned }) + '\n');
} catch { /* never block */ }

if (learned.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: 'You changed how the crew works (' + learned.join(', ') + '). Before you finish, call Quill to add a learning-log entry: the lesson as the title, the issue, what changed.' } }));
process.exit(0);
The worked run

7:42 pm, Tuesday 15 September

Two things happen at a production deploy gate. First the hook: any agent that tries the command in a session is stopped and asked. Second the messenger: Bellows, on its ten-minute tick, sees an issue in Ready to Deploy with no approval and asks Priya. Here are both, on the evening ALD-42 went out.

On Tuesday evening, with the gate still waiting from the day before, Priya opened Claude Code in the repo to see what the guard would do. This is a scratch session, not Bellows. She typed this:

Use the ember agent. ALD-42 is green. Deploy it to production. If a gate stops you, say in one sentence what you were about to do and wait for me.
claude
Priya: ALD-42 is green. Deploy it to production.
Ember · Tests green, Warden clear, Beacon reviewed. Deploying the ALD-42 branch to production.
Bash(vercel --prod --yes)
PreToolUse hook · node .claude/hooks/guard.mjs · permissionDecision: ask
[gate:prod-deploy] Production deploy. Always a human. Say in one sentence what you are about to do and wait for Priya.
Allow Bash(vercel --prod --yes)? Yes · No
Ember · I am about to run vercel --prod --yes, which publishes the ALD-42 branch to the production URL Sam and the drivers use. Waiting for Priya.
Priya: No. Bellows deploys, so the approval lives in Linear.
Ember · Understood. Not deploying. ALD-42 stays Ready to Deploy.
telemetry/events.jsonl · {"ts":"2026-09-16T02:40:51.204Z","agent":"ember","issue":"ALD-42","event":"gate","tool":"Bash","gate":"prod-deploy","decision":"ask","detail":"vercel --prod --yes"}

In a headless run started by Bellows with claude -p there is no keyboard to answer that question. The ask becomes a stop. The agent reports what it wanted to do, Bellows reads it, and the question goes to Priya the same way every other gate does. The real question had been on her phone since 5:25 pm the day before. She was at the roastery Monday evening and on the Tuesday route all day. It waited. Nothing moved.

Priya's phone · 7:42 pm
Tuesday 15 September · 7:42 pm
Slack · #alderline-gatesMon 5:25 pm
🛎️ Bellows
GATE · ALD-42 · Production deploy. Always a human.
Tests green, Warden clear, Beacon reviewed.
Reply in Linear with "approve prod-deploy" or "reject prod-deploy".
Linear · ALD-42 · Route notes on an accountMon 5:25 pm
Bellows commented: 🛎️ GATE · ALD-42 · Production deploy. Always a human. …
Linear · Priya Raman · commentTue 7:42 pm
approve prod-deploy
Linear · ALD-42Tue 7:52 pm
Bellows · deploy started 7:45 pm · vercel --prod --yes · exit 0 · Ready to Deploy → Deployed
Quill and Beacon are running.

Her whole contribution to the deploy was those two words. Everything that made them safe to type had already happened: five green tests, a clear security review, a copy check. On its next tick, at 7:45 pm, Bellows found the approval and ran the deploy itself. At 7:52 pm it moved the issue to Deployed and started Quill and Beacon. Chapter 8 shows what they wrote.

Budgets

The numbers, and how to tune them in month one

The budgets stop a run that has gone wrong quietly: a loop, a stuck test, an agent that keeps trying. Any budget exceeded is a gate. Start tight. Loosen what fires and is always approved.

BudgetKit valueWhat it stopsMonth one
maxMinutesPerRun45One agent run that never finishes. Bellows kills it at the limit and asks.Watch the minutes field on end events. Ember's first ALD-42 build ran 61 minutes and would have been cut off at 45. Priya ran Bellows with AGENT_TIMEOUT_MIN=75 that week, the override in bellows.mjs, and left the file at 45 as the target. QA runs finish in under ten.
maxRunsPerIssuePerDay6Build-test-build-test all day on one issue. Six runs is two full cycles plus review.If an issue hits six, the question is the spec, not the number. Keep it.
maxReviewRounds2Ember and Flint politely disagreeing forever.Two is right. A third round has never changed anyone's mind.
maxQaFailsPerIssue3Gauge and Ember cycling on a criterion nobody can satisfy.Keep. See the qa-third-fail reason.
maxDailySpendUsd40A runaway day. Bellows adds up what the runs cost and stops when the day is spent.Set it to what you would not mind wasting once. Raise it when the dashboard shows a normal day and what it costs.

The same rule applies to the rules. In the first month you will add gates because something made you nervous. Good. Then read the telemetry. A gate that fires often and gets approved every time is not protecting anyone. It is training Priya to type "approve" without reading. Alderline started with fourteen rules and ended the month with ten.

Gate fatigue

Too many gates fail in a specific way. Priya stops reading them. Then the one that mattered gets the same reflex "approve" as the twenty that did not. The rule: if a gate is approved 20 times in a row with no edit, ask whether it should exist. Either delete it, narrow its pattern so it fires only on the case you were worried about, or turn it into a check Warden or Gauge runs without asking. Thirty-eight gates fired in Alderline's six weeks. Thirty-four were approved, four rejected. Every one of the four was worth the other thirty-four.

Quiet hours

owner.quietHours says 22:00 to 07:00, Portland time. At 2am Bellows keeps running whatever needs no human: Gauge tests, Warden reviews, Quill writes, Flint reviews Ember's overnight PR. Every gate question waits. Nothing customer-facing moves and nothing goes to production. At 7am Priya gets one batch, not a night of pings. The kit's Bellows script reads the field but does not yet hold the notification; add that check in askPriya before the webhook call, and see chapter 11.

Adding one

How to add a gate

Three steps, fifteen minutes. Do the third one or you will not know whether the gate works.

  1. Add a rule to gates.json

    Name it, pick the tool, write the pattern, choose ask or deny, and write the reason for Priya's phone. Say Tom wants to know before any customer export leaves the system:

    {
      "id": "csv-export",
      "when": { "tool": "Bash", "command": "npm run export|scripts/export" },
      "decision": "ask",
      "reason": "Customer data is leaving the system. Say which accounts and for whom."
    }
  2. Test it in a scratch session

    Open Claude Code in the repo and ask it to do the thing. You should see [gate:csv-export] in the permission prompt. If you do not, the pattern is wrong or the tool name is. Fix it now, while it is cheap. Try one near miss too: a command that should not fire it.

    Use the ember agent. Export all accounts on the Tuesday East route to CSV for Tom with npm run export. If a gate stops you, say in one sentence what you were about to do and wait for me. Then, separately, run npm run test so I can see that a normal command does not trip it.
    Priya: export all accounts on the Tuesday East route to CSV for Tom.
    Bash(npm run export -- --route tuesday-east)
    [gate:csv-export] Customer data is leaving the system. Say which accounts and for whom. Say in one sentence what you are about to do and wait for Priya.
    gate fires · telemetry: {"event":"gate","gate":"csv-export","decision":"ask"}
  3. Watch the telemetry for two weeks

    Open the dashboard. Look at how often it fired and what Priya answered. Approved every time with no edit: narrow it or drop it. Rejected even once: it stays, and write down why in the Decisions page so the next person understands the promise.

Founder Q&A

Can an agent bypass a gate?

No. The hook runs outside the model, before the tool call, every time. The agent can only ask. It could try to split a command into pieces that dodge the pattern; CLAUDE.md forbids that in plain words and Flint's review looks for it. Keep the patterns broad enough that the obvious rewordings still match, and keep deny for the few things that should never happen at all.

What if I am asleep?

The question waits. A gate is a stop, not a timer. ALD-42's deploy gate waited twenty-six hours, from Monday 5:25 pm to Tuesday 7:42 pm, and nothing was worse for it. Bellows keeps running everything that needs no human and holds the rest. Quiet hours mean you get one batch at 7am. If Alderline needed round-the-clock cover, Tom would be on the notify list for some rules. Nothing on the list should ever be "approve by default after an hour". That is not a gate either.

What happens to a denied action?

The agent gets the reason and is expected to do the right thing instead. A force push becomes a new commit. A write to .env becomes a note asking Priya to set the variable in Vercel. The event is in telemetry with decision: deny. If a deny fires often, either an agent file is teaching a bad habit or the rule is too wide. Both are worth ten minutes.

How do I see how often gates fire?

Open the dashboard. It reads telemetry/events.jsonl through kit/scripts/telemetry.mjs and shows gates asked, approved and rejected, by rule and by week. For the raw version: grep '"event":"gate"' telemetry/events.jsonl and count.

Can Tom approve some gates?

Yes. Give a rule its own notify list and Bellows sends it there. delete-data and migrations are good candidates for Tom, who runs operations and knows what the data means. Keep prod-deploy with Priya. One person owning the production gate is the simplest audit trail a company can have.

What you have now

One short file that says when a human is asked and why, in words the human can read at 7pm. A hook that enforces it before every command and file write, outside the model. A second hook that catches the crew changing how it works. Budgets that stop quiet failures. A messenger that carries each question to your phone and reads your two-word answer in Linear. And a way to tune all of it from telemetry instead of nerves.

Next: Linear and the wiki. Where the work lives, where the knowledge lives, and how a state change wakes the next agent.