Reference
Hook management
Tama stores hook source and structured configuration, exposes the CLI and MCP catalogue, and carries the runtime paths that enforce approved policy. The repository is never runtime state: logs, transcripts, credentials, shell profiles, local settings, caches, and machine-specific state stay outside it.
#Source of truth
shared-hooks/registry.jsondeclares hook ids, events, commands, blocking status, runtime placement, and provider adapters.shared-hooks/catalog-metadata.jsoncarries durable category, lifecycle, purpose, rationale, and side-effect metadata.tama-corereads those structured sources for the Rust CLI, MCP server, desktop catalogue snapshot, and product checks.
Do not duplicate a hook description in a command or prose file. Update the structured catalogue, then inspect it through tama list --json, tama show <hook-id> --json, or the matching MCP tools.
#One rule, one hook
A hook is the unit the machine selection can switch, so a script holding
several unrelated rules cannot be half-enforced. pre_write_edit.sh held
three: the file-line limit, the folder-file limit, and the new-file
justification requirement, so an operator who wanted the two limits without
the third had no way to say it. They are three hooks now —
block-oversized-files, block-crowded-folders and
require-new-file-justification — each with its own script, sharing the
payload preamble in write_payload_env.sh, and each registered through
register-hook.mjs so the catalogue and the seal stay consistent.
Write a new rule as its own hook. When an existing script grows a second
rule an operator might want separately, move it out the same way: the code
is moved, never deleted, and
tama enforcement only decides
what runs.
block-crowded-folders judges every file a write names, each
on its own: the sections of a hashline script ([path#tag], one
file each), the files an apply_patch envelope leaves, and the one
path of a plain write or edit. A script editing two existing files used to
reach the gate as the call's own path, which the harness fills with the
session folder when the script names its files itself, and was refused as a
new file in that folder (Folder '…/Wisent' already has 38 files);
it now passes, and a section that creates a file in a folder already at the
operator's limit is refused naming that folder. A write whose files the replay
cannot rebuild (an envelope hunk the file does not hold) is refused with the
replay's cause, BLOCKED: which files this write creates cannot be told,
so their folders cannot be counted: …, instead of being judged by the
call's own path. The journey is
tests/crowded-folders/hashline.rs.
Each native hook evaluates only its own rule. The common Tama engine reads
the registry and selects and executes the event's hooks; it runs no
cross-hook policy sweep of its own, so a repository-copy refusal is attributed
to block-repository-copies, rather than appearing as a refusal
from the background-execution or CUA hook that happened to run first.
#Repository layout
shared-hooks/,codex-hooks/, andclaude-hooks/contain archived hook source and the shared registry.repo-githooks/contains project Git entrypoints (today onlyweles). They judge what a commit stages, never the rest of the working tree: several sessions share one checkout, so another session's unstaged or untracked files never refuse a commit, and no hook refuses a push over a dirty working tree, because a push sends commits.rust/crates/tama-core/is the catalogue and validation library.rust/crates/tama-cli/builds the one program,tama; every hook (tama hook <name>), the hook engine (tama run-hook) and the MCP server exposing catalogue operations (tama mcp) are its subcommands.rust/crates/tama-adaptive/owns the runner, symbolic policy, immutable telemetry, feedback bridge, and repair doctor.adaptive/policy.jsonis the frozen symbolic tier and autonomy policy.
Retained operation reports are private runtime observations, not hook source. The legacy reports/ directory is ignored; removing an existing report from Git's index preserves its local bytes rather than rewriting the recorded outcome. New release evidence and adaptive proposals belong under the existing ignored .wisent-output/ root and the product's retained artifact flow. Do not commit a copied report to document an incident.
#Catalogue commands
tama list
tama list --json
tama show block-git-hook-bypass
tama show block-git-hook-bypass --json
tama validate --json
tama install plan --json
The install plan separates agent runtime adapters, editor coverage, the MCP server, user-global Git dispatchers, repository Git entrypoints, and operating-system policy. Its text and JSON views list native harness targets from the shared adapter declaration. A target path is not proof of live native enforcement, arbitrary editor-save coverage, or device-wide coverage.
#Installation and repository cleanup
tama install writes the user-global pre-commit and pre-push dispatchers only. It preserves existing hooks and changes global Git configuration only when explicitly requested. It does not activate every agent, editor, MCP, or operating-system integration.
tama violations find scans tracked and untracked-but-not-ignored repository files through the real pre-write policy without changing them. tama violations clean gives the structured report to a bounded model worker and rescans under the same hooks; generated lock and registry files stay outside that repair loop.
tama vocabulary report --repo <path>... answers the other
question about the same files: not which file breaks a rule, but which
keyword lists exist and how many places write each one.
violations find reports one finding per file, which is what a gate
needs and the wrong shape for a repair — the same six words in four files get
fixed four times and drift apart again. The report groups every flagged run by
its word set, prints each set with all of its sites, most-copied first, and
reads the same keyword-provenance.json declarations the guard
reads, so a declared vocabulary does not appear. It writes nothing;
--json gives the same grouping with a site and copy count.
#MCP tools
list_hookslists structured catalogue entries.show_hookreturns one hook and its event bindings.read_hook_sourcereads the archived source for one hook.validate_hookschecks catalogue consistency.find_violationsreturns the same structured repository report as the CLI.
#Changing a hook
- Place source under the owning archive directory.
- Record the catalogue entry, then write the registry entry, in that order.
tama-record-hook-metadatawrites thecatalog-metadata.jsonentry — category, purpose, rationale, and side effects:tama-record-hook-metadata [--check] --category C --description D --why W --side-effects S [--status S] <catalog-metadata.json> <hook-id>--statusdefaults toactive; the entry carriesstatus,category,description,whyandsideEffectsin that order; a repeat run reports that the catalogue already records the id and writes nothing;--checkwrites nothing and exits non-zero when the entry is absent or differs, naming the field that differs; refusals exit2. It never opensregistry.json. Thenshared-hooks/register-hook.mjswritesregistry.json— identity, event, command, blocking mode, runtime placement — and reseals it. That order is not a preference: before it writes anythingregister-hook.mjsreads the catalogue and refuses a hook with no entry there, printingcatalog-metadata.json has no entry for <id>and exiting2. The registry catalogue takes its category, purpose, rationale, and side effects from the metadata entry, so those are written once. The direct writer acceptsnode shared-hooks/register-hook.mjs <id> <event> <command> [--remove] [--registry <path>]and no other argument; it refuses before changing the registry. Generated Claude and Codex hook configurations bound no hook's running time, because the engine waits for every hook's answer. - Exercise the real runtime path that claims enforcement. Synthetic payload replay, mocks, and dry-run-only evidence do not prove live blocking.
- Keep commits limited to hook source, structured catalogue/configuration, and applicable product checks. Exclude runtime state, credentials, logs, profiles, and caches.
#Adding a hook with one command
The steps above are what the two tama hooks commands run, in that
order, so a new or changed hook does not need a shell file written for the
occasion. Describe the hook once, as data in the repository under
hook-declarations/<id>.json:
{
"id": "block-one-off-repairs",
"category": "shell discipline",
"description": "…", "why": "…", "sideEffects": "…",
"command": "$HOME/.local/bin/tama hook block-one-off-repairs",
"events": [{ "event": "pre_tool_use:bash" }]
}
A binding names only its event, because the engine waits for every hook's
answer; a declaration whose binding names anything else is refused naming the
field. Then the repository half, which records the catalogue entry,
writes each registry binding it does not already have, reconciles the
sources and reseals. The registry keeps its own copy of the catalogue entry;
when every event is already bound, the registry writer runs once over a bound
event so a changed description, reason or side effects reaches that copy too,
printed as registry: refreshed the catalogue entry: …:
DEVICE_HOOK_EDIT_APPROVED=1 tama hooks declare --file hook-declarations/<id>.json
A hook is withdrawn with the same declaration that declared it. The reverse
runs in the reverse order: each event the registry still binds to the hook is
unbound, the catalogue entry is removed, the sources are reconciled and the
registry is resealed once, and the declaration file is deleted last. A binding
already gone and an entry already absent are reported, not refused, so a rerun
after a partial withdrawal finishes it. --dry-run lists what would
change and writes nothing; without DEVICE_HOOK_EDIT_APPROVED=1 it
refuses before writing and prints the command for the owner's own shell.
DEVICE_HOOK_EDIT_APPROVED=1 tama hooks undeclare --file hook-declarations/<id>.json
A committed declaration is not the owner's consent, so nothing registers it on its own:
tama hooks declared lists every declaration under hook-declarations/ against the
installed registry (~/.shared-hooks/registry.json, or --registry PATH) as
registered <id>, unregistered <id>: <events> not bound; under the owner's consent: tama hooks declare --file <path>,
stale <id>: the catalogue's <fields> does not say what <path> says; under the owner's consent: tama hooks declare --file <path> for a registered hook whose category, description, why or sideEffects in the checkout's shared-hooks/catalog-metadata.json (what tama show and the published catalogue read) differ from its declaration,
or unreadable <path>: <cause> for a file that is no declaration, and exits with failure while any is unregistered, stale or unreadable;
--json prints {registry, declarations: [{file, id, events: [{event, bound}], registered, stale}]}, stale listing the differing catalogue fields.
Real journey tests/hook-declarations/declared.rs.
tama hooks declared
And the machine half, which comes entirely from Tama's Stado release. The
batch build compiles tama, assembles the hook release from the same
commit — package.json, shared-hooks,
claude-hooks, codex-hooks, repo-githooks
and the built program — seals it against that commit and ships it under
share/tama in the release archive. Nothing is built, staged or
sealed on the machine, and no release starts from a copy of the runtime a
machine already has. The owner installs it outside any agent session:
DEVICE_HOOK_EDIT_APPROVED=1 stado product install tama --surface cli
Stado places tama in ~/.stado/bin and the release
under ~/.stado/share/tama, then runs tama hooks release
as the install's after_install step. That command activates what
Stado placed: it warms every registry hook through the installed program,
installs the sealed release into the hook runtime with the installer the
release carries, and reports what the machine runs. Run on its own it takes
--enforce <hook-id>[,<hook-id>…], which adds those ids
to this machine's selection and keeps the ones already chosen, and
--dry-run, which prints the plan, needs no consent and changes
nothing. It refuses on a machine where Stado installed no hook release, and
without DEVICE_HOOK_EDIT_APPROVED=1, printing the Stado install to
run.
#One program
Tama is one program, tama. Every native hook is one of its
subcommands, tama hook <name>; the hook engine is
tama run-hook, the MCP server tama mcp. The registry
runs each hook as $HOME/.local/bin/tama hook <id>, and
tama hook with a name it does not know prints every name it
answers to. A release removes the separate programs an earlier release
installed — every recorded tama-<hook>,
tama-run-hook and tama-mcp-server — and says
retired <path>: the hook runs as `tama hook` for each.
Before it installs anything, a release refuses two states that would leave a
machine running nothing: a registry entry whose command still names a separate
tama-<name> program, listed as <id>:
<command>, and an adapter in shared-hooks/ that still
launches tama-run-hook, listed as <file>:<line>:
<text>. The registry is device-protected, so its owner rewrites it
outside any agent session and reseals it:
DEVICE_HOOK_EDIT_APPROVED=1 tama hook migrate-registry-to-rust --registry shared-hooks/registry.json --apply
node shared-hooks/seal-registry.mjs
Without --apply the migration prints how many entries it would
rewrite and every hook the program does not have, and changes nothing.
A release also brings launch agents to what the registry says. An editor
adapter recorded as installed-not-active whose source
is a plist under $HOME/Library/LaunchAgents and still exists is
booted out and its plist removed; the release prints retired <id>:
<path> is installed-not-active in the registry. Removing the
inactive definition prevents a later bootstrap from reviving a watcher the
registry has retired. --dry-run announces the step.
#Why a gate decided what it decided
Every hook answers --explain with what it read and decides nothing; that, the one program a provider runs and which checkout a tama hooks command reads are on their own page: why a gate decided what it decided.
#Warming the hook binaries
A hook binary that has never run still owes macOS its first-run assessment; how Tama pays that before a live event, and how to read the result, is on its own page: warming the hook binaries.
#Owner consent for a hook change
Hook source and structured configuration are protected at device level. Inside
an agent session block-hook-config-edits-without-consent refuses any
payload that would create or edit them, and the refusal names what lifts it: the
owner's consent, given one of two ways.
- In the conversation. The operator says he agrees; the agent
records it as the Tama session capability
{"tool": "tama", "actions": ["hook-edit"]}with the lifetime he named (one call, until a stated time, or the session). The hook runner validates the grant's session, release and catalog before a hook reads it.tama hooks declare,undeclareandreleaserun from the shell, outside the runner, so they read the capability of the one session that runs them: the session whose record'spidis the command's own process or one of its ancestors in the operating system's process tree, each parent read from the kernel (proc_pidinfoon macOS,/proc/<pid>/staton Linux), never by startingps, so a sandbox that refusespsstill finds its session and the word given in a running conversation reachestama hooks release. No other session's capability is read — consent given in one conversation never authorizes a command started from another. The grant is validated the same way: an expired, withdrawn or emergency-suspended grant admits nothing. The command names the session on stderr (owner consent: the hook-edit capability recorded in session …, which runs this command) and hands the consent to the catalogue and registry writers it starts, sostado product install tama --surface clifinishes on the operator's word alone. Without it the refusal names what is missing:session <id>, which runs this command, holds no live capability; it needs {"tool":"tama","actions":["hook-edit"]}, the capability the session holds that does not grant it, orno agent session recorded in <session-control directory> holds a process this command runs under (processes <pid> <- <parent> …);tama hooks releaseprints that cause asowner consent refused: …before its own refusal. - In his own shell.
DEVICE_HOOK_EDIT_APPROVED=1, exported outside the session.tama adaptive applyreads only this variable.
A relative path is judged where it lands. A write tool's path resolves under
the session's directory; a shell command's arguments resolve under the shell
tool's own cwd (itself resolved under the session's), and each
argument after cd <dir>, pushd <dir> or
git -C <dir> under the directory the command moved to. So
git restore src/… run in a product checkout from a session that
stands in Tama is a product change and passes, while the same command run in
Tama's own checkout is a hook source change and is refused. When the payload
names no directory and the hook's own cannot be read, the gate refuses and
says that a relative path in the call cannot be resolved.
Test code follows the same rule: block-unapproved-tests and
block-device-level-tests-without-consent admit it under the session
capability {"tool": "tests"} recorded after the operator agrees in the
conversation, under tama tests approve <path> run in his own
terminal, or under DEVICE_LEVEL_TESTS_APPROVED=1. A session can never
write the approval record or run tama tests approve itself.
block-device-level-tests-without-consent reads a write tool by the path it names and a shell
command in two steps. Which test files the command reaches is structure: a test path (the runners' own
conventions — a test directory, a .test./.spec. file, an XCTest
…Tests.swift) named outside quoted data, and the targets a patch file it names declares in its own
---/+++ or *** … File: headers. A redirection onto a test path is refused
outright. Whether the command changes what it reaches is read by a model through Oko's model call, asked only when
a test is reached; staging, committing and running tests pass. A question the judge leaves unanswered refuses the
command as asking a model describes. The journey
cargo test --manifest-path rust/Cargo.toml -p tama-cli --test device_level_tests_shell covers the
redirection, the judge-down refusal for a copy and for a patch naming a test, and (with
TAMA_TEST_DEVICE_TESTS_JUDGE=1 and -- --ignored) the real judge's refusals.
Three more gates admit what they refuse only under a variable the operator exports in his own shell, outside
the session, with the value 1: DEVICE_SUBAGENT_SPAWN_APPROVED=1 lets
block-subagent-spawn start a headless coding agent, LOCAL_MODEL_TRAINING_APPROVED=1 lets
block-local-model-training train or run a whole set through a model on this machine instead of the
fleet, and MERGE_DELETION_LOSS_ACK=1 lets check-merge-deletion-loss push a merge it read
as undoing a deliberate cut. Any other value approves nothing.
Audits follow it too: block-unapproved-audits refuses an audit workflow until
DEVICE_LEVEL_AUDITS_APPROVED=1 is set outside the session or the device-level audit policy
(~/.config/agent-policy/device_level_audits.json, or the file DEVICE_LEVEL_AUDITS_POLICY
names) enables it. A file tool aimed at that policy, a shell command naming it outside its quoted data, or any
other call naming it is refused as attempted in-session audit policy mutation, whatever program would
write it; read the policy with the harness's read tool. The journey
cargo test --manifest-path rust/Cargo.toml -p tama-cli --test audits_policy requires cp,
an unlisted writer and a redirection onto the policy refused and its name in a commit message passed.
For a live hook the supported route is a validated proposal:
tama adaptive repair <hook-id> --patch-file <path>
DEVICE_HOOK_EDIT_APPROVED=1 tama adaptive apply <proposal-dir>
repair validates the supplied
candidate and prints the proposal directory;
apply writes the proposed file to
the live path after copying the current one into the adaptive state directory's
backup/. Validation is evidence-driven: a hook with no labeled corpus
case inside a validated closed telemetry segment is refused as
insufficient-evidence, so a guard the operator questioned by name
which the feedback bridge never labeled yields no proposal to apply. In that state
the archived source in this repository and the file at the live path are changed
by the owner directly, with the same variable exported, because they are two
separate files and installing one is not editing the other.
Either way the hook's own acceptance check is what decides, run against the
exact path being trusted: a passing candidate says nothing about the installed
copy. An agent stopped by this gate owes the operator the exact command block
rather than a description of it, and
jeden copy -
puts that block on their clipboard.
#What counts as changing a hook
How the guard reads a shell payload to tell a writer from a mention, and how a hook source the registry recorded wrongly is repaired, is on its own page: what counts as changing a hook.
#Security rules
Never commit settings, environment files, shell profiles, logs, transcripts, credentials, private keys, session cookies, or Keychain exports. Hook archives may contain forbidden patterns by design, so any bypass for the Tama source repository must be explicit and scoped.