Repo-specific guidance for Claude Code sessions goes here, above the managed org block: build/test commands, architecture notes, gotchas, and this repo's default reviewer. Rollout: (internal ref).
Two checkers, each with its own workflow and its own mutation harness. Run both before pushing:
python3 scripts/check_templates.py # D9 template rules
python3 scripts/check_zoo_paths.py --zoo ../model-zoo # zoo paths resolvecheck_zoo_paths.py is the cross-repo one: every model_zoo/... path
this repo prints — the guide's table, its MODEL_PATH default, the
README — must exist in a model-zoo checkout. It needs one beside this
repo (--clone fetches it), and it fails rather than skips when it
cannot find one. The guide's default MODEL_PATH pointed at a file the
zoo had stopped shipping (#11) precisely because nothing connected the
two repos; its input lives in another repository, which is why the
workflow also runs nightly rather than only on this repo's commits.
Both have a _mutations.py sibling that mutates a copy
of the tree and asserts every rule is SEEN to fail. Run it after editing
a rule: a checker passing says the tree is clean, only a mutation says
the checker can still fail.
- Org-wide automation — the reusable workflows every repo calls,
repo-inventory.yml, this file, the review dispatcher — lives inthe private source repo; per-repo callers pin@main.tracebloc/.githubis the org's public profile (README + issue templates) and carries no workflows; nothing new goes there. - Mirrors take no work. A
visibility: publicrow carryingmirror_of:inrepo-inventory.ymlis a publish target of the private source that row names — releases, a Helm index, a README — and nothing else. Never open a PR, push a branch, or file an issue about internal work on a mirror; do it in the source. Publishing is the source'smirror-publishworkflow through its publish guard and.publish-forbiddenlist, and the weekly anonymous exposure audit checks the mirror's tree against that same list. Which repos are mirrors today is the inventory's answer, not this file's.
- Branch model, for a repo on the release train:
develop → staging → main. Branch offdevelop; every PR targetsdevelop. Never open PRs tostagingormain— promotions are the train's job. - For a repo not on the train, do not infer the branch model from this file — read
repo-inventory.yml.release_train:says whether the model above applies at all, and the per-branchexempt:anchors record which branches actually exist. This bullet used to enumerate the exceptions by name and drifted from the inventory on every one of them:(internal ref)was calledmain-only while it had been on the train since 2026-08-04 (release_train: true,develop: required, staging present), and(internal ref)was calledmain-only while it had adeveloptaking merges (measured 2026-08-22, (internal ref) / (internal ref)). Restating the authority is the defect; pointing at it is the fix. - Trap, recorded in the inventory and caught by no check: a
developcreated on a non-train repo and left unprotected is invisible to the guards — that is thedevelop_unprotected_non_trainanchor, and the inventory notes "adevelopcreated and left UNPROTECTED is not flagged … no check was going to surface it." So creating one to satisfy the first bullet forks the repo silently: PRs split between the new branch and the repo's existing convention, nothing promotes between them, and the two heads diverge until someone reconciles by hand. If a repo appears to lack adevelop, that is a fact to verify in the inventory, not a gap to fill. - Before starting any task:
git fetchand branch from the current tip ofdevelop— never build on a stale checkout. A branch that lives more than a day getsdevelopmerged back in before review. We move fast; stale starts mean silent divergence and duplicated work. - One self-contained change per PR. A few hundred changed lines reviews well; at 1000+ split it. Refactors ship in separate PRs from behavior changes.
- Branches are short-lived (aim to merge within a day or two), single-author, and based on
develop— no stacked PRs on top of other open PRs. - Your branches are yours to clean up. Merged ones now delete themselves server-side, so this is about the rest: run
git reap(fromthe private source repo/scripts/git-reap) in your checkouts now and then. It is dry-run by default and only proposes a branch when it can prove the work landed. Nobody else can do this for you — you are the only one who knows whether an unmerged branch of yours still matters, andgit branch --mergedwill not tell you, because we squash-merge and a squashed branch is not an ancestor ofdevelop. - "Yours" is the branch you opened the PR for, never the branch whose last commit is yours. Pushing a review fixup onto someone else's branch makes you its tip-commit author and changes nothing about whose work it is — so a "my branches" list built from
%(authorname), or from the tip author in any form, aims your cleanup at other people's work. Measured: two of Shujaat'sclientbranches showed up on such a list and were one confirmation step away from--delete((internal ref)). If you are building any list that reasons about ownership, callthe private source repo/scripts/branch_owner.pyrather than re-deriving it; a branch it cannot attribute comes back asunattributable, which is the answer to act on, not to fill in. - Names and commits:
feat/ fix/ docs/ sec/ ci/ chore/+ issue number + short slug (fix/1234-ingest-timeout); commit subjectstype(scope): summary, referencing the ticket ((internal ref)).(scope)is the component —mint-scope,kanban— never the ticket number. A number in a PR title (sec(2157): …) is read byclosing-refas a reference the body must make good, in one of two forms:Closes <owner>/<repo>#Nwhen this PR really finishes the ticket, orPart of <owner>/<repo>#Nwhen it does not. Both satisfy the check; onlyClosescloses the ticket and moves its card, so never write it for partial work — and a bareCloses #Nresolves against the repo you are in, which for a(internal ref)ticket links the wrong issue. Keeping the number in the title is right either way: naming the parent is traceability, not a promise to close it ((internal ref)). - When you open a PR: assign yourself. The review account
tracebloc-reviewis the default — and the only required — reviewer: request it (on a repo whoserepo-inventory.ymlrow requires or carries acopyofdesk-dispatch.yml, the repo's CODEOWNERS rule* @tracebloc-reviewrequests it on every PR, whoever opened it — the review dispatcher does not staff, so that rule is the route that covers every PR). Where that dispatcher runs, its review is produced once the head's checks are terminal and green, and its approval satisfies branch protection's required review. Add a human reviewer only when you want one by name; there is no per-repo human default, and a PR with neither request, on a repo the review dispatcher does not cover, stalls by construction. - A PR body carries the template's
Release note:andSurface:fields, and whatever writes the body writes them. A body sent from a file (gh pr create --body-file, the body an agent skill composes) never passes through.github/pull_request_template.md, so nothing fills them in for you. Where the repo'srepo-inventory.ymlrow saysaudience: customer,release-note / checkrequires them on everyfeat/fixPR; what it cannot judge is the answer: ONE line in the customer's voice — what a user notices, never what the code does, and never a ticket number, repo name or customer name — with theSurface:where they notice it, or the literalnonefor CI, tests, refactors and internal tooling.noneis legal, and it is a decision, never a default. The template'sBilling / metering / numericsbox line belongs in the body too, ticked or not: on those repos the same check reads it on every PR as the author's decision, asks a person whether customers are told when it is ticked, and flags a body that carries no box line. - When a human is asked to review by name: first response within one business day. Where the review dispatcher runs — every repo whose
repo-inventory.ymlrow requires or carries acopyofdesk-dispatch.yml, which is the list, and which today is every repo this file reaches — it answers every green head within its next sweep; where it does not, the human you named is the whole of the review. Read the cell, not the wordrequired: acopyrow (model-zoo,quickstart— both public, so they cannotuses:a private reusable and carry the content-asserted render under.github/workflows/instead) runs the same dispatcher (list.github/self-contained/for the renders that exist; a count here went stale the day a twelfth render landed). - Lock labels are a session at work.
claude-desk:reviewingmeans the review account is reading or fixing that head;claude-ship:driving-<N>is the PR's one claim, held by whichever session is working it — the desk dispatcher takes it for every session it fires ((internal ref)). Never push to, re-request review on, or merge a PR carrying one — wait for the label to clear or for the session's comment. A bareclaude-ship:drivingis the retired ship dispatcher's lock ((internal ref)): nothing takes it any more, so one still on a PR is a dead session's, and a human takes it off. A lock past its TTL is stolen by the sweep, not by hand; a lock whose age nothing can read is the one a human takes off, and it is reported under two spellings because more than one tool reports it —desk-dispatchand every tool that shares its printer (dispatch-sweep,bugfix-sweep) printUNREADABLE-LOCK, while a--prdesk session refuses withLOCK AGE UNREADABLE— so search the logs for either. None of them fires anything on an unreadable age, exactly as none fires on a held one, so that state clears only by hand.
- Before every push: run the linter and the tests that cover your change. Never push a branch you believe is red — CI is the backstop, not the first run.
- Read the full diff before opening the PR. You own every line you ship, whoever — or whatever — wrote it.
- AI sessions end with evidence, not assertion: run the relevant check (tests, build, lint) and show the output. A change that could not be verified does not ship.
- Fix the class, not the instance. The bug you just fixed is a member of a class; check the rest of the class before you push. Two shapes, and aiming at only the first catches half of them: other call sites — grep the symbol or pattern you changed — and other inputs to the same guard — what else reaches this branch? If the class can't be cheaply enumerated, say so in the PR rather than leaving it implied that you covered it.
- After opening a PR the review loop takes it wherever the review dispatcher runs (the
desk-dispatch.ymlrows above): the review account reviews a green head and, with FIX MODE on, is fired on a red or conflicting head to fix it — Bugbot, CI and review findings — and the PR merges through GitHub auto-merge once branch protection's bar holds ((internal ref); the ship dispatcher that used to fix and merge as you retired under (internal ref)). Where it runs, do not poll CI or Bugbot yourself (gh pr checks --watch,gh run watch, a loop of either): the account's GitHub API budget is one bucket shared by every session, routine and CI job, and polling has emptied it. Where it does not, triaging your own PR is still yours. What comes back to you isneeds-human— a session stood down on something it may not or cannot fix; read its comment and decide. No silent dismissals: every finding is fixed or answered on its thread, because unresolved threads block the merge and stall the release train's settle stage. - A Bugbot finding measured unreachable is pinned, not argued, and waits for nobody's word. When a finding, or a ticket filed for one, names code that no input reaches, it is settled by this path, in this order:
- Measure and post. Construct the input the finding needs, run it, and post the input, the command and the result on its thread.
- Pin it in the same PR. The PR that answers it adds a test or guard that goes red once an input reaches the code; the thread names it.
- Second reader. The review account, never whoever measured, checks both and resolves the thread; missing either, it stays a finding.
- Close. The pinning PR
Closesany ticket filed for the finding, and its opener posts one comment there whose first line is exactlyClosed: unreachable, pinned by <owner>/<repo>#<number>, naming the PR. That marker counts only on a ticket the named PR closed. It tells; it asks nobody.
- A finding that recurs across PRs becomes a rule: add it to
.cursor/BUGBOT.md, and if it is grep-expressible, to code-quality's house-rules — then stop re-arguing it in comments. - Style and naming rules live in tooling (black/ruff, eslint/prettier, house-rules), never in prose. If a rule matters, encode it; do not restate linter rules in CLAUDE.md files.
- Never commit secrets, tokens, or customer data — not in code, config, tests, issues, or commit messages. gitleaks catches secrets in code. Nothing scans PR titles or descriptions: the public PII gate that did was retired on 2026-08-06 ((internal ref)), so keeping customer names out of PR prose on public repos is on you, not on a check. Commit messages are the one exception, and only on a public repo whose
repo-inventory.ymlrow requires thecommit-hygiene.ymlcopy (the content repos):hygiene / commit-messagesscans every commit of a PR for internal references — ticket numbers into private repos, private repo names, RFC ids, derived from the inventory — and names a hit by kind and message line, never by text ((internal ref), (internal ref)). What it does NOT scan for on a public repo is tenant names: the public render carries no tenant needles, because the needles are tenant names and a public repository's runner is not where they belong — the org ruleset that used to refuse those names there had to spell them in a regex GitHub serves anonymously. So on a public repo, keeping customer names out of commit messages is on you, exactly as it is for PR prose. (The private original of that workflow keeps the tenant scan — needles from the org secret, a hit by needle number, a refusal rather than a pass when the wired secret resolves empty or the head is a fork — and its job is gated to public repositories, so the public render is the live shape.) On every othervisibility: publicrow, and for PR titles and bodies everywhere, internal references are on you as well: the exposure audit scans the files this org delivers there, not the prose you type. - A guard reads the real thing, and fails closed. Derive, never restate: a check holding its own copy of the rule agrees with itself while disagreeing with reality, so it parses the real declaration. An unreadable input, an absent file or zero parsed items means "cannot tell", and "cannot tell" is a finding, never a pass. The reasoning, and the ~23 guards that verified nothing, are in (internal ref).
- A guard is proven by breaking it. Every guard, check or test that claims to catch something is mutation-proved: break the thing it guards, watch its named case go red, restore it, and assert that the mutation's anchor actually applied, because an inert mutation and real coverage print the same log. The mutation calls the code under test, never an inline copy of its rule, and the inputs are written down independently of the matcher: a list tested against itself is blind. For an enum, alias or vocabulary, derive the input domain from the producer's declared surface and test all of it; mutation coverage cannot see a value nobody wrote down. Arm a gate only while it is green. A red nightly mutation run is a bug, not noise: it goes in as a
work-type:bug, so intoReady, and is fixed like one. Whether every guard has a mutation row at all is the coverage gate's to check ((internal ref)), not this bullet's.
- Every ticket on the board carries a
Status— no card sits at "No Status": the weeklykanban-reconcilesweep places any open card it finds there by the rule this bullet states ((internal ref)). New tickets start inBacklog. Bugs are the exception: label themwork-type:bug(the Bug template does it) and automation moves the card straight intoReady— defects don't wait for refinement. This holds in every repo, and the exception that used to be written here is gone rather than kept accurate by hand: the labels exist fleet-wide, andtriage-labels.ymlasserts daily that every label the templates apply and the caller fires on exists in every repo declaring that caller. A hand-written exception list drifts on every entry — it named two repos while the inventory said three — so the fix was to empty it ((internal ref)). - The one card that stays off: an issue labelled
off-board. It marks a standing ledger or a bot's record, never a work item. The weeklykanban-reconcilesweep never adds or restores its card and archives a live one, so labelling an existing issue takes it off within a week. Use it only for such records. It is for issues only: a PR carrying it stays on the board, becausefr-gatefails closed on a PR missing from it. The name is declared once, inscripts/lib/off-board.json: a reader reads it from there, and a copy anywhere else is held to it by a check. - Picking up work: the team coordinates.
Readyis the refined queue — bugs excepted, per the line above — and the first choice when it's stocked; pulling fromBacklogis normal when refinement hasn't caught up — say what you're taking. - Merging to
developmoves the card toOn devautomatically; there is no dev-side review. - Functional review happens once, on staging: when it passes, comment
/fr-passon the PR or drag the card toReady for prod. Self-signoff is allowed. fr-gateis a required check on promotions. If it blocks, the board or the work isn't ready — fix that. If it fails with the annotationFR gate could not verify(exit 3), it judged nothing: GitHub did not answer a read it needed (a rate limit, an unanswered API call, an unreadable board, a range whose changed files it could not count), and it has already walked once more where the wait fitted the job, so re-run the check once GitHub answers; nothing on the board needs fixing.skip-fr-gateis audited, for emergencies only.
- The release train is the only path to
staging,main, and every package registry. Never hand-cut av*tag or publish an artifact — every legal publish path is inventoried in (internal ref)'sPUBLISH-PATHS.md. (Thehand-bump a version fileclause was removed on 2026-09-08: it contradicted a REQUIRED check, and the meta-rule above says an enforced rule leaves this file.version-bump-gate / version-checkfails a PR that touches a published path while the version file still reads an already-released version — "Bump package.json in this PR. The release train reads that file and cuts the tag from it — it never bumps for you." So the bump a feature PR ships is the train's INPUT, not a bypass of it. Read literally, the old clause forbade what the gate demands: it blocked two component PRs on (internal ref) until someone put the two side by side, and a reviewer there opened and then retracted a change-request over the same collision.) - Findings on a promotion PR are fixed on the source branch (
develop/staging), then the train re-prepares. Never push fixes onto a promotion PR — every push re-rolls its review.
- Internal work — planning, epics, security findings, infrastructure, anything mentioning a customer — is filed in
(internal ref)(the private catch-all), never in a public repo. When in doubt:(internal ref). - Public repos -- every
visibility: publicrow inthe private source repo/repo-inventory.yml-- only get issues a stranger could act on: about the public artifact itself, with no customer names, internal URLs, or internal paths. This bullet used to enumerate them by name and drifted. Restating the authority is the defect; the inventory is the list.
- An interactive AI session may open PRs and push its own branches. It never: merges a PR, closes another person's PR, deletes another person's branch, or force-pushes — each of those needs an explicit instruction from the human running it. The one exception is the review account's session, whose APPROVE arms GitHub auto-merge on the PR it approved ((internal ref)), so GitHub merges it when — and only when — branch protection's bar holds; that routine's firing is the standing instruction. (The ship dispatcher's
ship --prsession held this exception until it retired, (internal ref).) - Functional sign-off is a person's. An AI session never comments
/fr-passand never removesfr-hold, even when it writes with your own token. The handler refuses a machine account, a comment an App posted, and a hold a machine released. A session using a person's token is that person to GitHub, though, so no check can refuse it and this rule is the only guard. The handler knows a machine User only if it is declared: a new machine account with no[bot]suffix (a service or review account, a session's own login) goes intoscripts/lib/machine_logins.py, in lower case, in the PR that gives it write access. An account nobody declared is a person to every check that asks ((internal ref)). - If your change makes a statement in any CLAUDE.md, BUGBOT.md, or runbook false, update that file in the same PR.