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
- Names are host-assigned, collision-free, origin-bound. A name is minted by the host, never supplied by the model or a server. Two origins declaring
searchgetalpha__searchandbeta__search; a collision after provider normalization (search-a/search_a) fails closed. - The descriptor is pinned by digest of
(origin, raw name, schema, effect class). A material change is a different tool, and an approval can be bound to it. - An undeclared effect class fails closed — it becomes
irreversible. - Nothing here reads a clock or a random number: a registry is a pure function of its declarations.
Two orthogonal words
| Word | Question | Values |
|---|---|---|
ApprovalPolicy | does this call need a human at all? | REQUIRED, NEVER |
EffectClass | how 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
| Tool | Approval | Effect | What it does |
|---|---|---|---|
scratch_read | required | read | one read-only command (ls, cat, wc, head, tail, file, stat) inside --scratch |
file_read | required | read | a bounded, line-framed read of a workspace file; returns the content digest the next edit must cite |
file_edit | required | reversible | an exact-string replace bound to that digest; refuses stale / ambiguous / missing; re-reads before it reports success; returns a diff |
file_write | required | irreversible | a whole-file write; replacing an existing file needs explicit consent |
file_find | required | read | a bounded, sorted glob listing of workspace-relative paths; truncated + nextCursor |
file_search | required | read | a bounded content search returning path + line + the clipped line — never a file body |
skill_list | required | read | the know-how cards available on disk, one line each, sorted by name |
skill_load | required | read | one card, returned whole; an unknown name is refused with the name it could not find |
bash | required | irreversible | the 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 | — | irreversible | one 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 |
plan | never | idempotent-write | declare or revise the turn's plan — a reading for the human, not an action |
desk | never | idempotent-write | the thread's workdesk page: read, or write the whole page. Bound per lane; a write replaces, never appends |
task | never | read | delegate one bounded, read-only investigation to a child thread and get back a typed envelope |
apply_change | required | irreversible | apply a finished write child's diff to the main checkout — the one writer |
bank_search | required | read | the store's semantic search. Offered only with the store's address and credential present |
bank_get | required | read | one post by slug, in full |
librarian | required | read | one approved hunt: two to four angles over the store in one call, answering a ranked manifest — a map, not the territory |
research | required | read | one 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 kind | Tools |
|---|---|
| read-only scout | the read half: file_read, file_find, file_search, skill_*, bank_*, research, plan |
| write child | file_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.