Docs/Reference

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.

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

  1. shared-hooks/registry.json declares hook ids, events, commands, blocking status, runtime placement, and provider adapters.
  2. shared-hooks/catalog-metadata.json carries durable category, lifecycle, purpose, rationale, and side-effect metadata.
  3. tama-core reads 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/, and claude-hooks/ contain archived hook source and the shared registry.
  • repo-githooks/ contains project Git entrypoints (today only weles). 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.json is 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_hooks lists structured catalogue entries.
  • show_hook returns one hook and its event bindings.
  • read_hook_source reads the archived source for one hook.
  • validate_hooks checks catalogue consistency.
  • find_violations returns the same structured repository report as the CLI.

#Changing a hook

  1. Place source under the owning archive directory.
  2. Record the catalogue entry, then write the registry entry, in that order. tama-record-hook-metadata writes the catalog-metadata.json entry — 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>
    --status defaults to active; the entry carries status, category, description, why and sideEffects in that order; a repeat run reports that the catalogue already records the id and writes nothing; --check writes nothing and exits non-zero when the entry is absent or differs, naming the field that differs; refusals exit 2. It never opens registry.json. Then shared-hooks/register-hook.mjs writes registry.json — identity, event, command, blocking mode, runtime placement — and reseals it. That order is not a preference: before it writes anything register-hook.mjs reads the catalogue and refuses a hook with no entry there, printing catalog-metadata.json has no entry for <id> and exiting 2. The registry catalogue takes its category, purpose, rationale, and side effects from the metadata entry, so those are written once. The direct writer accepts node 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.
  3. Exercise the real runtime path that claims enforcement. Synthetic payload replay, mocks, and dry-run-only evidence do not prove live blocking.
  4. 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.

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, undeclare and release run from the shell, outside the runner, so they read the capability of the one session that runs them: the session whose record's pid is the command's own process or one of its ancestors in the operating system's process tree, each parent read from the kernel (proc_pidinfo on macOS, /proc/<pid>/stat on Linux), never by starting ps, so a sandbox that refuses ps still finds its session and the word given in a running conversation reaches tama 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, so stado product install tama --surface cli finishes 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, or no agent session recorded in <session-control directory> holds a process this command runs under (processes <pid> <- <parent> …); tama hooks release prints that cause as owner consent refused: … before its own refusal.
  • In his own shell. DEVICE_HOOK_EDIT_APPROVED=1, exported outside the session. tama adaptive apply reads 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.