The tool catalog

Everything the model may call. There is exactly one declaration site — Toolset.kt in daemon/src/main/kotlin/dev/staage/daemon/ plus the per-tool declarations() calls — and one object holds both halves: catalog (what the provider renders) and tools (what the loop dispatches to). They are two projections of one list, so they cannot drift.

Seventeen names at most. Eleven are always present; task needs delegation, apply_change needs worktrees, and four rails appear only when their credentials do.

The rules the registry enforces

Two orthogonal words

WordQuestionValues
ApprovalPolicydoes this call need a human at all?REQUIRED, NEVER
EffectClasshow much effect does the approved call have?read, idempotent-write, reversible, irreversible

A policy is not an effect class. plan needs no approval and is an idempotent-write; bash needs approval and is irreversible; a read can still be REQUIRED, because reading is an outbound act and the human is the one who decides when this machine reaches.

The catalog

ToolApprovalEffectWhat it does
scratch_readrequiredreadone read-only command (ls, cat, wc, head, tail, file, stat) inside --scratch
file_readrequiredreada bounded, line-framed read of a workspace file; returns the content digest the next edit must cite
file_editrequiredreversiblean exact-string replace bound to that digest; refuses stale / ambiguous / missing; re-reads before it reports success; returns a diff
file_writerequiredirreversiblea whole-file write; replacing an existing file needs explicit consent
file_findrequiredreada bounded, sorted glob listing of workspace-relative paths; truncated + nextCursor
file_searchrequiredreada bounded content search returning path + line + the clipped line — never a file body
skill_listrequiredreadthe know-how cards available on disk, one line each, sorted by name
skill_loadrequiredreadone card, returned whole; an unknown name is refused with the name it could not find
bashrequiredirreversiblethe resident's own command line — one command through /bin/bash -lc, exactly as written, with the daemon's environment. The containment is the sandbox mode (product / bench / off), stated at boot; the approval is the other half
dev_check—irreversibleone gate script (script/check-*.sh) run inside a dev tree's copy of a repository. Declared, but not composed by the shipped boot: DevCheck.kt holds the declaration and Guard.kt its timeout, and the current boot offers bash instead. A gate is one command line
planneveridempotent-writedeclare or revise the turn's plan — a reading for the human, not an action
deskneveridempotent-writethe thread's workdesk page: read, or write the whole page. Bound per lane; a write replaces, never appends
taskneverreaddelegate one bounded, read-only investigation to a child thread and get back a typed envelope
apply_changerequiredirreversibleapply a finished write child's diff to the main checkout — the one writer
bank_searchrequiredreadthe store's semantic search. Offered only with the store's address and credential present
bank_getrequiredreadone post by slug, in full
librarianrequiredreadone approved hunt: two to four angles over the store in one call, answering a ranked manifest — a map, not the territory
researchrequiredreadone live question answered from the open web, with citations. Leaves this machine for a third party; pinned to the cheapest tier

task appears only when the boot enabled delegation; apply_change only when it also named a worktree root; the four rails only when Rails found their credentials. --require-rails turns a missing rail into a refusal at boot rather than a quieter daemon.

The four rail names, and what fails closed

Rail.kt / Rails.kt. A rail is one request/response read, as a value. The daemon ships four: bank_search and bank_get (the knowledge store), librarian (its multi-angle door), research (the open web). Every endpoint arrives from the environment — a store endpoint belongs to whoever runs the daemon, so hardcoding ours would ship one operator's infrastructure inside a stranger's binary. check-hygiene.sh enforces that no shipped file names an internal service.

Per-turn narrowing: the tool profile

ToolProfile.kt — the wire carries the flag and the turn carries the profile. A prompt may switch a tool off for its own turn (the librarian is the first one that can be switched off this way), and a child gets a narrower set than its parent by construction:

Child kindTools
read-only scoutthe read half: file_read, file_find, file_search, skill_*, bank_*, research, plan
write childfile_read + at least one of file_edit / file_write, in a disposable worktree — no bash, no tests, no process

A child's catalog is the parent's with every approval-required call denied as approval.child_unattended — no human is attached to a child, so a tool that would need one is not offered rather than silently permitted.

The vocabulary of a tool result

Every tool answers in a small, sealed vocabulary: ok with a payload, or a typed refusal with a name (file.stale, file.missing, file.ambiguous, find.invalid, bash.exit, task.objective, approval.child_unattended, …). Refusals are readings, not failures — a non-zero exit is information, and the loop keeps it.

Bounds are always named: truncated with a nextCursor, scanCapped when a walk hit its budget, counted skippedBinary / skippedLarge / clippedLines. A tool that silently clipped its answer would make the model lie with confidence.