Skip to content

Latest commit

 

History

History
1547 lines (1383 loc) · 96 KB

tools.md

File metadata and controls

1547 lines (1383 loc) · 96 KB

Tools — MCP API Reference

Plumb exposes 60 structured tools to AI assistants. Every write tool is concurrency-safe, atomic, and notifies the language server via workspace/didChangeWatchedFiles.

This page documents each tool's purpose and inputs. For day-to-day workflow, see Getting Started; for the bigger picture, see Architecture.

Client capabilities and fallback behaviour

Client Native filesystem Native shell/git Notes
Claude Desktop None None Plumb is the only interface — no fallback tools exist
Claude Code Read / Edit / Write Bash Plumb adds LSP-semantic tools with no native equivalent
Codex Shell (shell tool) Yes Plumb adds an LSP-semantic layer and concurrency-safe writes
Gemini CLI Filesystem tools Yes Plumb adds an LSP-semantic layer and concurrency-safe writes

Conventions

These apply across many tools:

  • Paths and URIs. Every tool accepts an absolute path or a file:// URI for its file argument — the filesystem tools (file_path / path) and the LSP query/edit tools (uri) alike. Filesystem tools additionally accept a workspace-relative path, resolved against the session's workspace root; the uri tools need an absolute path or file:// URI (the language server requires an absolute URI).
  • Positions are zero-based. LSP query/edit tools that take a line and character use zero-based numbering (matching the LSP spec). Output line numbers are printed one-based.
  • Position or name. get_definition, find_references, call_hierarchy, rename_symbol, and read_symbol accept either a position (uri + line + character) or a symbol name (symbol_name / name, plain or dotted ReceiverType.MethodName). Prefer names when available; plumb resolves them to the identifier's SelectionRange.Start and avoids hand-computed positions. A plain name matches a method at any depth, Go methods included (gopls names them (*Recv).Method). A symbol literally carrying the name beats a method that matches only once its receiver is stripped, so Run is the function Run even beside a method (*S).Run. That includes a struct field or an interface method named Run: either hides every (*T).Run from a plain Run lookup, and the method is addressed by its receiver (T.Run; T/Run for move_symbol). Only when no symbol carries the name itself do methods compete, and then equally good matches (two methods (*A).Run and (*B).Run) are never resolved to one silently: the read-only tools answer for every match; rename_symbol refuses and lists, for each, the Receiver.Method name that singles it out, or the line/character to retry with when no name does; move_symbol refuses and lists the name_path for each (A/Run, resolved against the language server's flat (*A).Run symbol and, when the server cannot answer, the topology index). A generic receiver's type parameters are left out: (*S[T]).Run is S/Run, and S[T]/Run names the same method, as do *S/Run and (*S)/Run; a bracket never closed (S[/Run) names nothing. When the tree-sitter fallback resolves a name_path because the language server cannot (replace_symbol_body, insert_*, move_symbol), Parent/Name names a declaration whose direct parent is Parent, and P1/P2/Name one whose parent is P2, whose parent is P1 — never one that merely sits somewhere inside Parent, so Outer/run is Outer's own run, not that of a class nested in it. A declaration's parent is the smallest named node enclosing it, or the node its extractor links it to (a Rust method to the type of its impl block, when the file declares that type), though not the package node Go links every top-level declaration to (p/Run is refused) or a C# file-scoped namespace; a qualified name that spells out the whole chain ((*S).Run, Foo::run) counts too. A path no declaration matches is refused, and so is one that several match — overloads, a class nested in a class of the same name, a C++ or Objective-C declaration beside its definition, a TypeScript class beside a same-named one in a namespace — with their lines listed. A TypeScript namespace is not a node, so no path through one resolves. Neither case is ever resolved to a same-named declaration elsewhere in the file.
  • dry_run. The LSP semantic-edit tools (rename_symbol, replace_symbol_body, insert_*, safe_delete_symbol) default to dry_run: true — they preview the change. Pass dry_run: false to apply.
  • dirty_ok. Filesystem and semantic write tools refuse to touch a file with uncommitted git changes that plumb did not write this session, unless you pass dirty_ok: true. Disable the guard entirely with [edits] block_dirty_writes = false (or PLUMB_BLOCK_DIRTY_WRITES=0).
  • expected_mtime / expected_sha. read_file and read_symbol emit a header line — # plumb-read mtime=<RFC3339Nano> sha256=<hash> indent=<…> — whose mtime/sha256 you can pass back to edit_file or write_file for optimistic concurrency checks (the write is refused if the file changed since you read it). Sole-agent fast path: for a burst of sequential edit_files to one file you can omit expected_mtime and rely on the exactly-once old_string match as the safety check — each successful edit returns a fresh mtime, so threading it forward is needless friction (and a stale value you carry over would be rejected). Reach for expected_mtime/expected_sha only when a concurrent writer may touch the file between your read and your write.
  • Atomicity on transport failure. Every write stages to a temp file then renames it into place (atomic on POSIX), so if a call dies with a transport/connection error (Connection closed) the file on disk is either fully written or untouched — never partially written. Re-read to see which side of the rename it landed on rather than assuming corruption.
  • Crash durability (fsync-before-ack). Before a write is acknowledged, the staged temp file is fsynced and so is the parent directory after the rename, so a successful call survives a hard crash or power cut — the data and the directory entry are both on stable storage. Directory-fsync failures are logged but never fail an already-landed write (some filesystems refuse them). Disable with [edits] fsync = false (or PLUMB_FSYNC=0) for benchmarks or exotic filesystems.
  • Automatic staleness guard. Even without expected_mtime, if this session read a file and it then changed on disk before your write, write_file refuses (pass overwrite_changed: true to override) and edit_file warns but still applies. A file the session never read — including a brand-new one — is never flagged, and a write you yourself just made is not a change, so consecutive edits to your own file are never flagged.
  • Line-number gutter. read_file and read_symbol prefix every content line with its 1-based file line number and a tab (cat -n style), so range math is exact. The gutter is display-only — strip the leading <n>\t before using a line as an edit_file or find_replace old_string. (If you forget, edit_file strips an unambiguous pasted gutter for you and says so.)
  • # plumb-note / # plumb-warn lines are out-of-band annotations plumb adds to a tool response (large-file file_outline nudges, staleness warnings, a one-shot "daemon reconnected" note after a transparent reconnect). They are informational — never part of file content.

Session

session_start

Bootstrap tool — call first in every session. Returns workspace path, language, git branch, the first 200 lines of .plumb/context.md, memory names/descriptions, top-5 tool usage, 5 recently-modified files, 3 recent commits, the live git tool policy (whether commits/destructive/push are enabled), and active diagnostics. When [collab] peer_awareness is on and other sessions are active on the workspace, it also appends an "Active peers" digest naming them and the areas (directories/packages) they recently touched. Idempotent.

Both file-census walks — the Scale: line's count and the recently-modified list — honour .gitignore / .ignore on top of a hardcoded floor (.git, node_modules, vendor, dist, build, .idea, and every dot-directory), so a workspace's generated output is neither counted as its size nor listed as its newest work. Both stop at 50 000 files; a capped count renders as ~50000+ files rather than reporting the ceiling as a measurement. Both are skipped entirely on a protected macOS root (fsguard). Inputs: workspace (string, optional — defaults to the daemon's resolved workspace, then a cwd walk); language (string, optional — force the primary LSP language when detection cannot infer it); session_id (string, optional — the caller's own stable conversation or agent id; see Identity below); purpose (string, optional — a human-readable tag for this session, e.g. deploy-fix; letters, digits, and - only, max 32 chars; surfaced in the TUI session list, daemon_info, and workspace_sessions. An invalid value is rejected with a clear error).

Identity. On a connection that several logical agents share (Claude Code's subagents inherit the parent's one plumb serve), the daemon keys each agent's workspace pin, read tracking, undo history and rate budget by a logical-agent id, and refuses a state-changing call it cannot attribute. The id reaches the daemon on three channels, strongest first: a per-call _meta["dev.plumbkit/logical-agent"] set by the client's transport; the same key, or plumb_agent, placed by a client runtime as a top-level key inside arguments, which the daemon lifts out before any tool sees it (Claude Code's plumb hooks install claude-code PreToolUse hook is the emitter — it stamps every mcp__plumb__* call and also fills session_id on this tool); and session_id itself, which identifies this call only: on a connection other agents share, a later write without a per-call identity is refused. It is also the declaration a per-call identity needs there: a state-changing call whose conversation half no successful session_start on the connection has declared (its session_id, or the per-call identity it ran under) is refused with that remedy, plus workspace so the declared agent is not left on the connection's root, unless every identity on the connection belongs to one conversation. A subagent stamped <conversation>/<agent> rides its conversation's declaration and works in its conversation's workspace, following it when it re-pins. Pass a stable value per agent: the conversation id for a main thread, <conversation>/<agent> for a subagent. The session record — the name mail is addressed to, plumb mail --external-id, name inheritance on resume — is linked to the conversation half, so a subagent declaring itself never rewrites its parent's linkage. Passing the same id again is idempotent; a session that never passes one still has a name and is addressable by it, but a client restart starts a new identity. The daemon cannot tell a runtime-injected stamp from a typed one; the trust boundary is the connection, as for _meta (see docs/threat-model.md).

daemon_info

Current session name and ID, daemon version, the source commit the daemon binary was built from ((dirty) when the source tree had uncommitted changes, or an explicit unknown for a binary built without a revision stamp — see docs/cli-reference.md), Go runtime, OS/arch, start time, and uptime; the session's purpose tag when set; how many plumb daemon processes are running for this user (the account is meant to have exactly one, and the row names the remedy when the count is wrong or could not be read); the workspace pin's provenance when known (how, when, and from where the pin was last set — pin-drift observability, issue #182); live config-store state (generation, last reload time, whether a restart is needed); and this session's tool-call count plus its slowest calls (per-call durations from recorded stats). Inputs: none.

version was merged into this tool: the old name still works as an unadvertised alias (it took no arguments, and the answer is a superset — the Go runtime and OS/arch rows version reported are here) and the result carries a notice pointing at daemon_info.

rename_session

Rename the current MCP session. Inputs: name (string — letters, digits, and - only; user-provided case is preserved; max 25 chars).

The name must be free — free of every LIVE session, and of every name RESERVED by a durable identity that is disconnected but recoverable. The second is what stops a plumb serve outliving its daemon from having its name handed away during the outage.

A reservation is held FOR someone, so it does not refuse the party entitled to it: the identity that owns the name takes it back on reconnect, and so does a restarted plumb serve that re-links the same conversation (session_start's session_id), which holds neither the old proxy secret nor the old session ID but is the same agent. That second entitlement is only as strong as the conversation ID the client supplies — the same basis session_start's resume-by-linkage has always had.

Reservations are enforced from the GLOBAL configuration: [session] persist_state set in a project's .plumb/config.toml does not switch them off, because this guard protects other sessions' names rather than the calling session's own state. With the global setting off they are not enforced at all — nothing would then be able to reclaim one.

A session name is the address the mailbox delivers to, so a name another live session already answers to is refused (compared case-insensitively), as is next — the next-arrival address. Renaming to the name you already hold is allowed, and an ended session whose identity is not recoverable does not reserve its name. Generated names are checked the same way at registration and re-drawn, so no two registered sessions share one. (A session whose registration failed keeps a display name but has no mailbox address at all, so it cannot shadow one.)

workspace_sessions

Same-workspace peer awareness: lists active sessions on this workspace and recent mutating operations. Useful before editing a file a concurrent agent may have touched.

Inputs: recent_limit (integer, optional, 1–50, default 10) — max recent-write entries to return.

Output sections:

  • you — this session's name.
  • active_sessions — sessions currently connected to this workspace with their client identity, optional purpose tag, and idle status. A single session with is_self=true means you are the only agent here — your view of the workspace is authoritative.
  • recent_writes — the last N write/edit/rename/git/… operations by any session on this workspace, showing the writer, tool, relative file path, and age. The writer is the session name, or <session>/<agent> for a write made while several logical agents shared that connection — so four subagents working through one plumb serve are four writers here rather than one. A bare name means the connection had a single agent when the call was made, so the name identifies it exactly. Only operations that could modify the workspace are listed: read-only git subcommands (status, log, diff, …) and dry-run previews never appear, and a call that failed or was refused is kept but marked [failed — no change applied] — evidence of peer activity, not a change to re-read. A successful git commit is attributed in full: its line carries the writer, the commit's short SHA and subject, and the repository; other git writes are labelled with their subcommand (git add, git push). If a file you are about to edit appears here, re-read it first. When [collab] peer_awareness is on and the topology index has the file, each entry is annotated with its enclosing package/symbol (best-effort, source=topology).

Read-only. Workspace-boundary-guarded (no other workspace's session data is ever exposed). Backed by internal/session (session files) and stats.RecentWritesByWorkspace (the tool_calls stats table). Both data sources are read under a 500 ms hard timeout so the tool never blocks the MCP response.

When [collab] intents is on, the output also lists each active session's live intent (an unverified claim, distinct from the observed recent_writes); when [collab] mailbox is on it lists the messages addressed to the caller that are still unread (listed, not claimed here — reading them is check_messages' job). Neither ever creates collab.db.


Cross-agent sharing ([collab])

Four advisory tools for concurrent agents. Each is gated by its own [collab] flag and refuses with a clear enable hint when the flag is off. Everything is advisory — nothing here ever blocks a write — secret-scrubbed (internal/redact) before storage and byte-budgeted when injected. What an agent says is a claim, rendered distinctly from what the daemon observed (phase-1 peer awareness). share_intent and same-project messages live in <workspace>/.plumb/collab.db (WAL, auto-gitignored like topology.db), created lazily on first use: intents expire per [collab] intent_ttl_minutes, unread notes per [collab] note_ttl_minutes (falling back to intent_ttl_minutes), and with [collab] keep_delivered_notes = true a claimed note is stamped far-future at delivery — the transcript survives while unclaimed rows are pruned on the daemon session-reaper tick; share_findings instead writes a durable generated memory.

Delivery is by polling only — plumb does not push to another agent. That single constraint shapes the mailbox. It is a property of plumb, not of MCP: some clients do expose a server→client path that reaches a session between turns, and plumb wires none of them, so "polling only" holds for every client plumb supports today rather than being a limit of the transport. Messages are appended to the result of any successful tool call, so an agent that is working receives them without asking; check_messages blocks server-side so an agent that is waiting can hand its turn over instead of spin-polling. An agent idling on its human makes no tool calls at all and will not see a message until it does something — silence is not a refusal.

share_intent

Broadcast what you are working on so peers can steer around it (e.g. "refactoring the rate limiter — avoid internal/tools/ratelimit*"). You have at most one live intent — a new call replaces it — and it is cleared automatically when your session ends. Peers see it in workspace_sessions, and a peer whose write touches a path matching your path_globs gets a bounded advisory hint labelled [Peer intent (claim, unverified): …]; a peer about to run a repo-state git op (switch/rebase/reset/commit/…) on a repository your intent covers gets a similar warning. Requires [collab] intents = true.

Inputs: body (string, required — free text); path_globs (array of strings, optional — workspace-relative globs for the area you are working on); ttl_minutes (integer, optional — expiry override; defaults to [collab] intent_ttl_minutes).

leave_note

Send a message to a named peer session, or to next (whoever attaches to this workspace next). The send half of the mailbox.

Every message belongs to a conversation. Omit conversation_id to start one (the reply reports the new id); quote the id you were given to answer in thread. A thread is capped at [collab] max_exchanges messages — once spent, further replies are refused with an instruction to summarise for the human.

Be clear about what that cap is worth. plumb cannot observe a human turn, so it counts total messages in a thread, not consecutive agent replies; and starting a new conversation_id starts a fresh budget, which nothing prevents. It is a speed bump that makes continuing a deliberate act, not an enforced ceiling on how long two agents may talk.

Each message is delivered exactly once, by check_messages or the recipient's next session_start — the two calls whose result the model asked for. The block appended to an ordinary tool result is a preview: it shows the message early and marks nothing read, because whether a client surfaces text the model never requested is the client's business, not plumb's. A harness that runs plumb's tools inside a sandboxed program discards those results, and while the block claimed, that silently consumed the message. A previewed note is still waiting; the sender still sees it unread until the recipient takes delivery. Notes addressed to next are previewed as a count only, never a body — every session in the workspace is a candidate and exactly one wins the claim.

A delivered message stays in the store until its TTL, which is what gives a conversation its transcript and its exchange count — or, with [collab] keep_delivered_notes = true, past it: claiming stamps the row far-future, so the read flag is the permanence trigger and only unclaimed mail ages out. One delivery hands over at most three messages, oldest first; when a batch fills that cap the block also states how many more were waiting at that moment and points at check_messages (no wait) to drain them before replying.

A message is addressed to a session, not to a name. When the peer you name is connected, the message is bound to that exact session and only it can ever read it. Session names are reusable — an ended session does not reserve its name, and any session may rename_session to a free one — so without that binding a message its recipient never read would be handed to whoever next answers to the name, bodies included, while the sender was told it reached the peer it meant. The trade is deliberate and stated in the reply: a bound message expires unread if its recipient never comes back, which is the right failure for a private message. Addressing a peer that is not connected stores no binding and is delivered by name, as is next — that one is a first-claimer race by design. Messages written before this existed are likewise unbound and keep delivering by name. A daemon restart does not orphan bound mail: the reconnecting session RESUMES the identity its durable record proves it holds, so it reads its own mail under its own ID — and the record is selected by the proxy session ID plumb serve replays in its handshake, never by answering to a name. While that identity is recoverable its name stays reserved, so no new session can be given it and come back to find its mail addressed elsewhere.

When the identity cannot be resumed — a predecessor connection still detaching, or a genuinely different live owner — the session runs under a temporary one and, if it holds the predecessor's name, inherits that predecessor's ID as a second mailbox identity so mail still reaches it. That is the degraded path: the durable record is left untouched and a later reconnect resumes the identity properly.

Addressing a session pinned to a different workspace is refused up front unless that project has already set [collab] cross_project = true — the send fails with a clear reason rather than being accepted only to sit unclaimed until it expires. The exception is another checkout of the same repository: a linked worktree and its main checkout, sibling worktrees, or a submodule and its superproject (either way round, and transitively). Those are one project and need no opt-in. Membership is git ownership verified on disk, never path nesting: a worktree's link must be confirmed by the back-link git writes inside the repository's own .git, so a plain directory nested in a project, or a repository cloned inside it, is still another project. Once confirmed, cross-project messages are stored in a daemon-level database, never in the recipient's directory — no session ever writes into another project's workspace — and are labelled with the sending project when read. next is always same-project: "whoever attaches next" has no meaning across projects. Requires [collab] mailbox = true.

Inputs: body (string, required — the message); to (string, optional — a peer session name, or next (default)); conversation_id (string, optional — reply into an existing thread).

check_messages

Read messages other agents have sent you, optionally waiting for one. The receive half of the mailbox, and what makes an actual back-and-forth possible.

With wait_seconds omitted or 0 it returns immediately with whatever is waiting. With a positive value it blocks until a message arrives or the wait expires, capped by [collab] max_wait_seconds (default 55 s, kept below the client's own MCP call timeout). That is how a session hands its turn to a peer after asking something, at a cost of one tool call per turn rather than one per poll. Requires [collab] mailbox = true.

It also reports your own unread mail: every message you sent that nobody has read yet, with its age, appended to whatever else it returns. That is the one fact a sender cannot otherwise observe — plumb does not push, so "no reply" means either the peer read it and has not answered or never read it at all, and only this separates the two. It matters more since messages became bound to a session, because a bound message expires unread rather than passing to a successor. It reads both stores regardless of your own cross_project setting (that flag gates what this project will read from another, and these rows are your own), which is also where it earns most: a cross-project message to a recipient who never opted in expires unread by default. Listing is a read — it never consumes the message on the recipient's behalf. Silence means everything you sent has been picked up.

Inputs: wait_seconds (integer, optional — block up to this long; 0 default).

share_findings

Hand off what you have just learned as a durable, searchable memory now, instead of waiting for the idle episodic summary that fires when your session ends. It rides plumb's generated-memory pipeline end-to-end: the body is secret-scrubbed, stamped with your session and the date as provenance (confidence=generated), written under <workspace>/.plumb/memories/ as finding-<timestamp>-<session>, and FTS-indexed. Peers discover it through the ordinary channels — search_memories, workspace_search, relevant_memories, memory hint injection, and the next session_start. It is agent-generated content: labelled lower-confidence than a user-written memory and never displacing one in a capped hint slot, and it counts against the same [memory] generated_memory_keep retention pool as an idle episodic-* summary. Rule-based only — you supply the text; there is no LLM summarisation. Requires [collab] knowledge_handoff = true.

Inputs: summary (string, required — a one- or two-line headline, stored as the memory body); description (string, optional — longer detail appended below the summary); paths (array of strings, optional — workspace-relative globs the finding is about, stored as frontmatter so relevant_memories and hint injection route it to those files).


LSP queries

workspace_symbols

Search symbols by name across the entire workspace via the LSP index; stdlib/dependency hits are filtered out. Prefer over text search for name lookups. Pass uri to restrict the same search to a single document — a case-insensitive substring match over that file's symbol tree, children included. Inputs: query (string, required), uri (string, optional — absolute path, file:// URI, or workspace-relative path; omit it for the workspace-wide search). Falls back to the topology index (annotated source=topology, mode=indexed-approximate) when the LSP errors or times out and [topology] is enabled. find_symbol was merged into this tool: the old name still works as an unadvertised alias — query and uri pass through unchanged, a uri-less call now runs the workspace-wide search it used to redirect to, and the result carries a notice pointing at workspace_symbols.

get_definition

Source location where a symbol is defined. Inputs: uri (required), and either line + character or symbol_name.

explain_symbol

Hover documentation and type information for a symbol. Inputs: uri (required), and either line + character or symbol_name.

file_outline

A token-cheap skeleton of a file: every function, type, method, class, and constant rendered as its signature line with the body collapsed, nested by containment, with byte-precise 1-based line ranges — a 2000-line file's shape in a few hundred tokens, so you can decide what to read without reading it. Inputs: uri (required), include_docs (bool, default true — prepends the first line of each symbol's leading doc comment). Symbols come from the language server (documentSymbol) when one answers; when the server is cold or does not cover the file it falls back to the tree-sitter topology index (so the outline still works for files no warm LSP serves), and the output is annotated source=lsp or source=topology. Multi-line signatures are joined; the body opener ({) and everything after it is stripped. Shares the documentSymbol cache with the other symbol queries, so a warm outline reuses an existing query. list_symbols was merged into this tool: the old name still works as an unadvertised alias (its include_signatures flag is dropped — the outline always renders signature lines) and the result carries a notice pointing at file_outline.

find_references

All usages of a symbol across the workspace, each with its source line. Inputs: uri (required), either line + character or symbol_name, include_declaration (bool, default true).

call_hierarchy

Incoming and outgoing calls for a function. Inputs: uri (required), and either line + character or symbol_name.

type_hierarchy

Supertypes and subtypes of a class or interface. Inputs: uri (required), and either line + character or symbol_name.

diagnostics

LSP errors, warnings, and hints. Inputs: uris (array of file:// URIs — omit or pass [] for all files with issues; one URI for a single file; many URIs to batch). A single call replaces multiple per-file calls. The plumb-diagnose skill puts this in the wider triage loop: reading a refusal, getting compile truth, and telling a broken tool from a server that has not warmed up.

Mode-aware: on a connection negotiated for pull/hybrid ([lsp.<lang>] diagnostics = "pull", see Configuration), the tool pulls on demand via LSP 3.17 textDocument/diagnostic — reusing result IDs and unchanged reports, folding in related documents, and pulling multiple URIs at bounded concurrency; a push-mode connection keeps the existing open-and-wait behaviour. A pull that fails never reports a false "No issues" — it surfaces the error plus the last-known cached diagnostics, explicitly marked stale or unverified. The no-URI (whole-workspace) query runs a workspace/diagnostic sweep only when the server advertises that capability; otherwise it returns the cached view plus an honest note that only already-analysed or already-pulled files are covered — pass uris to check specific files.

The same honesty applies to a cold server. While a language server is still completing its handshake it does not fail — it simply has not published anything yet, so a report taken then would read as a clean bill of health. Any report assembled while the server is warming is therefore labelled INCOMPLETE, with the elapsed warm-up time and a daemon_info pointer: a clean result there is not proof the code compiles. The label is applied to every outcome, not just an empty one (a partial set can be missing whatever the server has not reached yet), and a multi-URI batch is labelled when the server behind any of its URIs is warming — batches can span languages, and so servers.


LSP semantic edits

All default to dry_run: true. When applied, semantic edits use the same write-tool bookkeeping as filesystem writes: path locks, dirty guards, workspace/didChangeWatchedFiles, cache invalidation, undo capture, topology refresh, quality hooks, and differential post-write diagnostics.

Prefer these over edit_file for whole-declaration changes. replace_symbol_body / insert_before_symbol / insert_after_symbol / safe_delete_symbol address a declaration by name_path (e.g. "ClassName/methodName") — no line/character coordinates, no hand-computed ranges, no read-modify-write of the surrounding text. Reach for edit_file when you're changing a fragment within a declaration (a few lines, a string, a single statement); reach for the symbol-edit family when you're replacing, inserting around, or deleting an entire function, method, type, or other named declaration. Neither edit_file nor transaction_apply requires raw coordinates by default — both default to old_string / new_string string matching, and transaction_apply has no coordinate mode at all; edit_file's line/character range mode is an optional fallback, not the primary path.

rename_symbol

Workspace-wide rename via LSP — scope- and type-aware, updates every reference. Inputs: uri, new_name (required), either symbol_name or line + character, dirty_ok, dry_run (default true), structural_fallback (default false). Prefer symbol_name; raw positions recover from narrow "no identifier" misses by snapping once to the enclosing symbol's identifier. When the language server cannot compute the rename (an error, or an empty edit set — common with sourcekit-lsp before the build graph resolves), the tool returns actionable guidance. Pass structural_fallback=true to fall through to a best-effort, identifier-boundary text rename via find_replace (word-boundary match across same-extension files, honouring dry_run) — not scope-aware, so review the preview before applying. The response carries a per-file unified diff of the change — a preview in dry-run, the applied change otherwise — unless show_write_diff is disabled; diffs are capped at 20 files with an "and N more file(s)" summary. The structural-fallback path instead surfaces find_replace's own match output.

replace_symbol_body

Replace a symbol's entire declaration. Inputs: uri, name_path, content (required), include_doc_comment (bool), dry_run, dirty_ok.

insert_before_symbol

Insert text immediately before a symbol's declaration. Inputs: uri, name_path, content (required), include_doc_comment (bool), dry_run, dirty_ok.

insert_after_symbol

Insert text immediately after a symbol's declaration. Inputs: uri, name_path, content (required), dry_run, dirty_ok.

safe_delete_symbol

Delete a symbol only if it has no external references (reports them and refuses otherwise). Inputs: uri, name_path (required), include_doc_comment (bool), dry_run, dirty_ok.

move_symbol

Relocate a whole top-level declaration (function, method, type, const, or var) from one file to another within the same directory/package, atomically — the declaration is removed from source_uri and appended to destination_uri in a single all-or-nothing write (destination-write failure rolls the source back, so the symbol is never duplicated or lost). Inputs: source_uri, name_path, destination_uri (required), include_doc_comment (bool, default true — a relocated declaration keeps its doc comment), create_destination (bool, default false; a created Go file is seeded with the source's package clause), dry_run (default true), dirty_ok. Locates the symbol via the LSP document-symbol tree, falling back to tree-sitter when the server is cold. Conservative v1: it does not rewrite references or imports, so a move that would change the symbol's package or import path — a different directory, or a different Go package clause — is refused rather than applied half-correctly. Also refuses an ambiguous bare name, a missing destination without create_destination, out-of-workspace paths, and — for Go — a destination whose build constraints differ from the source's: explicit (//go:build/// +build comments) or implicit (the _GOOS/_GOARCH/ _GOOS_GOARCH and _test filename-suffix conventions, e.g. handlers_linux.go or foo_test.go), compared independently — not for cross-axis equivalence, so a plain file with //go:build linux moved into a _linux.go file is still refused. Moving a declaration between differently constrained files would silently change what compiles per platform/tag, or drop it from the production build entirely (the _test.go case). The response carries a unified diff of both files (preview in dry-run, applied change otherwise) unless show_write_diff is disabled. Undo is per-file: reverting a move takes two undo_edit calls (source, then destination), with a transient intermediate state where the declaration is duplicated in both files.

name_path is a slash-separated symbol path within the file, e.g. "ClassName/methodName" or just "funcName" for a top-level symbol.

include_doc_comment extends the edit over the doc comment of the symbol the tool resolved, and never resolves the name_path a second time: the extractor's byte-precise span when a tree-sitter node of that name starts on the symbol's first line, the contiguous comment lines above it otherwise.

All four append a unified diff of the change to their response — a preview in dry_run, the applied change otherwise — gated by [edits].show_write_diff (default on; same toggle as edit_file/write_file).


Filesystem reads

read_file

Read a file's text. Inputs: file_path (required), plus an optional line window — either plumb's start_line + end_line (1-based, inclusive) or Claude Code's native offset (first line) + limit (line count). start_line and offset are synonyms; limit and end_line are mutually exclusive. Binary files rejected; output capped at 200 KiB. Emits the # plumb-read … header. Each content line carries a display-only 1-based line-number gutter (<n>\t, cat -n style) — strip it before reusing a line as an edit old_string.

Search-within-file mode. Pass pattern to search the file instead of windowing: each matching line is returned with its 1-based line number (and optional context), so an over-cap file stays searchable in one tool. The whole file is scanned line-by-line regardless of size; only the output is bounded. Search inputs: pattern (literal text by default; a Go RE2 regex when use_regex), case_sensitive (default smart-case — case-insensitive when the pattern is all lowercase), context_lines (0–50, like rg -C; disjoint groups get an -- separator), max_matches (1–2000, default 200; output is truncated and labelled beyond it). pattern may be combined with start_line/end_line (or offset) to restrict the search to that line window, but not with limit (rejected — use max_matches). When a start_line/end_line window is set, context_lines is clipped to that window too — context never spills match lines from outside the restricted range. A summary line (# plumb-search: N matches for …) precedes the results; a no-match search returns an explicit message, not an error; an invalid regex returns a clean error. Mirrors search_in_files' literal/smart-case conventions.

read_symbol

Read the source body of a named symbol in one call (LSP documentSymbol + file read). Inputs: path (required), name (required — plain or dotted ReceiverType.MethodName). Returns all matches when ambiguous. Body lines carry the same display-only line-number gutter as read_file.

read_multiple_files

Read up to 20 files in parallel; per-file errors reported inline. Reads are recorded per file exactly like read_file, so a batch-read file is editable under [edits] strict mode with no re-read. Inputs: paths (array, 1–20, required), plus the same slicing/search parameters as read_file — applied uniformly to every path in the call, with no per-path override: start_line, end_line (1-based, inclusive; window every file the same way), pattern (search every file instead of windowing it — literal by default, a Go RE2 regex when use_regex), use_regex, context_lines (0–50, like rg -C), max_matches (1–2000, default 200). A windowed batch read still records each file's FULL mtime/sha in the read tracker (identical to read_file's own ranged-read behaviour — strict mode is mtime-based, not range-based) and its per-file header still carries baseline (the whole-file byte size), so a lines=2 slice is distinguishable from a 2,000-line file. The 20-path cap is unchanged by slicing. When every successfully-read file agrees on one indent convention (3 or more files), it is stated once in a # plumb-read-batch indent=… preamble instead of being repeated per file.

find_files

Glob/regex file or directory finder, and plumb's directory lister. Inputs: pattern (optional — omit to match everything), path, type (file | dir | any, default file), extension, exclude (array of globs to exclude; matching directories are pruned, matching files skipped), max_depth (1 lists one level, like ls), max_results (default 500), include_hidden, include_details, sort_by (name | size | modified, default name), use_regex. Honours .gitignore. Glob patterns support brace alternation (*.{ts,tsx}), including nested and repeated groups; an expansion past 256 alternatives or 10 levels of nesting is refused rather than truncated. include_details renders each entry with a [FILE]/[DIR]/[LINK] marker, its size and modified time (symlinks as name -> target) instead of a bare path list.

list_files and list_directory were merged into this tool: the old names still work as unadvertised aliases (list_files' root is mapped to path and its depth default of 8 is pinned; list_directory is served as max_depth:1, type:"any", include_details:true), each with a notice naming find_files. Both now inherit find_files' .gitignore confinement in place of list_files' hardcoded exclude list.

search_in_files

ripgrep-style content search; smart-case; honours .gitignore. Inputs: pattern (required — literal text by default, a Go RE2 regex only when use_regex is true), use_regex, path, glob (supports brace alternation, e.g. **/*.{go,md}), exclude (array of globs), case_sensitive, context_lines (0–50), max_results (default 200), include_hidden, max_file_bytes, include_enclosing_symbol (bool — annotates each hit with the deepest enclosing LSP symbol; requires LSP). Total output is capped at 200 KiB; truncation is labelled.

A literal-mode search whose pattern contains regex syntax appends a one-line note saying so, since the alternative reading of a clean "No matches" is "these don't exist". Unambiguous syntax (a single | — || is excluded, since boolean-or is ubiquitous — .*, .+, a leading ^, and regex escapes such as \./\d/\w/\s) is flagged whether or not the search matched; shapes that are also ordinary code ([...], (...), {n,m}, a trailing $) are flagged only when nothing matched. find_replace and read_file's pattern mode carry the same note.

file_status

Lightweight, read-only "did this file change under me?" probe — no content read. Inputs: paths (array, 1–50, required). Per path reports git_dirty (uncommitted vs git HEAD/index — untracked counts as dirty), changed_since_plumb_wrote (on-disk mtime advanced since plumb last wrote it this session), last_writer (plumb | external | unknown), mtime, and size. Missing files are reported, not an error. Does not satisfy strict mode's read-before-edit requirement — it is a status probe, not a read.


Filesystem writes

All hold per-path locks, write atomically (tmpdir → rename), notify the LSP, invalidate the symbol cache, consume one rate-limit slot, and accept dirty_ok (default false).

write_file

Create or overwrite a file atomically; post-write diagnostics appended. expected_mtime / expected_sha (from a prior read_file header) reject the write if the file changed since you read it — the same optimistic-concurrency guard edit_file has, so a whole-file overwrite never silently clobbers a concurrent change. Even without those, a write is refused when this session read the file and it then changed on disk (a peer or human edited under you); pass overwrite_changed: true to override. A never-read / new file is never flagged. Inputs: file_path, content (required), expected_mtime / expected_sha (optional concurrency check), overwrite_changed, dirty_ok.

edit_file

Targeted str_replace with a uniqueness lock and CRLF tolerance. When this session read the file and it then changed on disk (with no expected_mtime passed), the response carries a # plumb-warn note — the edit still applies (the old_string anchor protects the edited region) but surrounding context may have moved, so re-read before further edits. Inputs: file_path (required), edits (array of {old_string, new_string} — each old_string must appear exactly once), expected_mtime / expected_sha (optional concurrency check), apply_partial (bool — apply each edit independently), dirty_ok. Replacing an entire declaration? Prefer replace_symbol_body (see LSP semantic edits above) — addressed by name_path, no coordinates needed.

Line-range edits. An edits entry with start_line (and optionally end_line, default start_line; -1 runs to the last line) replaces whole lines instead of matching old_string; start_line: -1 appends. new_string is treated as whole lines: when it is non-empty and has no trailing newline, the line ending of the replaced text is added, so the edit never joins the next line. A range running to the end of a file with no final newline (or an append to such a file) keeps the file without one. An empty new_string deletes the range. Several range edits in one call apply in order, each to the content the previous one produced.

Anchor-bounded mode (alternative to edits). Instead of an exact old_string, supply start_anchor + end_anchor (two unique substrings) and a new_string that replaces the span they bound. The two request shapes are mutually exclusive — provide either edits or the anchor trio, never both. Each anchor must match exactly once (ambiguous/absent → the same clear error as old_string), end_anchor must occur after start_anchor, and the matcher mirrors str_replace: CRLF-tolerant and forgiving of a pasted display-only read_file gutter (<n>\t). include_anchors=false (default) replaces only the text between the anchors, leaving them in place as stable boundaries; include_anchors=true replaces the whole inclusive span (anchors included). An empty new_string deletes the span. Everything downstream — the per-path lock, expected_mtime/expected_sha guards, LSP notify, cache invalidation, diff output, and write-rate budget — is the same write path as edits. Ideal for rewriting a block whose interior changes but whose boundary lines are stable. Anchor inputs: start_anchor, end_anchor, new_string, include_anchors (bool, default false).

delete_file

Delete files and empty directories (refuses directories unless allow_dir, and even then only when empty — there is no recursive delete). The response reports the line and byte count removed (bytes only for a binary or oversized file), then the diff of what was deleted — every line removed — for up to 20 paths in a batch (… (+N more file(s)) beyond that), gated by [edits].show_write_diff. A path matching [history] sensitive_globs reports … (diff withheld: sensitive path) instead of its content, and one too large to read reports the withholding rather than a diff against nothing. Inputs: file_path or paths (array, max 100 — not both), dirty_ok, allow_dir.

paths batches round-trips, not semantics: every path obeys the same per-path rules. All paths are validated — boundary, existence, allow_dir, dirty — before any is removed, so a batch that will be refused is refused whole. Removal order is files first, then directories deepest-first, so listing a tree's files together with its directories works in one call. Each path consumes a rate-limit token. To clear a tree: find_files its contents, then one delete_file batch with allow_dir: true.

rename_file

Primary move tool. Atomic move/rename. Inputs: from, to (required), overwrite (bool — required to clobber an existing target), dirty_ok.

A rename does not change content, so its response carries no diff — with one exception that matters: when the move destroys an existing destination, that destination's content is rendered as removed lines, because nothing else in the transcript shows it. Gated by [edits].show_write_diff.

copy_file

Duplicate a file, preserving permissions; cross-device safe. The response shows the diff of what landed at the destination (every line added when the path was free; a real before/after diff when it replaced an existing file, which is the part nothing else in the transcript shows). Gated by [edits].show_write_diff. Inputs: from, to (required), overwrite, dirty_ok.

transaction_apply

Multi-file atomic edits with rollback (up to 50 ops). Validates everything in memory, then writes under locks, rolling back on partial failure. The response lists each file with a per-file unified diff, gated by [edits].show_write_diff. Inputs: operations (array of {file_path, edits, expected_mtime?}), dirty_ok.

undo_edit

Revert plumb's most recent write to a file — the safe alternative to a whole-file git checkout/git restore, which discards every uncommitted change in the file. Restores only what the last edit_file/write_file changed (deleting the file if that write created it), and refuses by default when the file has changed since plumb wrote it (an external or peer edit), so it never silently clobbers someone else's work. Single-level per file (a fresh write re-arms it); undo history is per session and cleared on a workspace switch. Pre-write content over 1 MiB is not snapshotted, so undo is unavailable for very large files. Both branches — the restore and the removal of a file the write created — return a diff of what the undo changed, gated by [edits].show_write_diff. Inputs: file_path, force (override the changed-since-write guard).


Memory

Per-workspace markdown notes at <workspace>/.plumb/memories/, also exposed as MCP resources. Names are constrained to [A-Za-z0-9_-]+. Markdown files are the source of truth; <workspace>/.plumb/memory.db is only a rebuildable search index.

Agents should call write_memory for durable project knowledge: conventions, architecture decisions, gotchas, validation commands, or resolved bugs. Pass workspace-relative paths globs when the note applies to specific files; those frontmatter globs drive relevant_memories and automatic path hints. The plumb-memory skill walks the whole lane — search before re-deriving, the search modes and when each is wrong, what belongs in a memory, and deleting one that has gone false.

Tool Purpose Inputs
list_memories List all memory names + descriptions. optional workspace
read_memory Read one memory. memory name
write_memory Create or overwrite a memory. name, content, optional description, optional paths globs
delete_memory Remove a memory. memory name
search_memories Ranked FTS search with grep fallback across memory bodies. search pattern
relevant_memories Memories whose paths: frontmatter matches a file path. file path

When [memory] generated_summaries = true, plumb also writes conservative, redacted generated memories for idle sessions that touched workspace files. These are named episodic-*, carry generated provenance, and are pruned by [memory] generated_memory_keep (default 50; 0 disables pruning). They summarise activity only; they do not infer architectural lessons.


Topology

A persistent SQLite/FTS5 semantic index at <workspace>/.plumb/topology.db. On by default ([topology] enabled = false opts out); all tools degrade gracefully when it's off. See the Topology guide.

topology_status

Index health: file count, entity count, DB size, indexed languages, last sync, last error, and a FAILING flag while the last indexing cycle has errored (the indexer retries with backoff; meanwhile topology_search, topology_explore, topology_impact, topology_affected, topology_routes and structural_query lead every answer with a ⚠ STALE INDEX notice). Inputs: none.

topology_search

FTS5 ranked symbol/file search. Inputs: query (required), kinds (filter), language (filter), limit (default 20), include_snippets (default true), rerank (optional). When [semantics] is enabled (opt-in; an embedding API or a self-run OpenAI-compatible endpoint), results are re-ranked by semantic similarity to the query — the output is annotated mode=fts+semantic. FTS5 stays the authoritative spine: re-rank only re-orders its candidates and falls back to plain FTS5 (mode=ranked) on any error. Pass rerank:false to force the plain ranking, rerank:true to force re-rank when configured.

workspace_search

Ranked discovery broker across the workspace's indexed corpora: code and docs (Markdown/HTML sections) via the topology FTS5 index, and memory via the memory FTS5 index. Results are ranked within each corpus and interleaved round-robin by per-corpus rank (raw FTS5 scores are not comparable across indexes); every hit is labelled corpus, source, field, score, and why, and the header reports per-corpus index freshness (fresh|stale|building|missing|skipped) plus exact_match=false — this is discovery, never proof of absence. A stale memory index still serves (honestly labelled) and kicks an async reindex. Decision rule: use workspace_search for conceptual questions ("where is daemon locking handled?"); use search_in_files for exact literal/regex matches over current file contents. Ladder: workspace_search → topology/LSP → search_in_files → bounded read_file — the plumb-explore skill teaches the same ladder, with the signal for leaving each rung. Inputs: query (required), corpora (optional subset of code/docs/memory; default all), limit (default 20, max 100).

topology_explore

BFS neighbourhood around a named symbol. Inputs: name (required), depth (default 2, max 4), max_nodes (default 50, max 200), max_bytes (default 30000, max 100000), include_source (none = name only | signatures (default) | docstrings = signature plus docstring; snippets/full accepted as aliases; complete function bodies belong to read_symbol), edge_kinds, path, kind. Exploring a type/class node also lists its member methods (members (N)). N counts every member found; members cut by max_bytes are reported as … K more member(s) omitted, and a type with more than 50 members reads members (50+) with a pointer to workspace_symbols (a Go type's methods can span files, so one file's outline is not the whole set). Budgeting is on symbol boundaries: each whole symbol is costed against max_bytes for the chosen source mode and added only if it fits in full, so a truncated result is always a set of whole, coherent symbols — never a fragment of a function. none omits the signature bytes, so more neighbours fit under the same budget.

topology_impact

Bidirectional blast-radius: what a symbol depends on and what depends on it. Inputs: name (required unless mode="reachability"), depth (default 3, max 4), max_nodes (default 100, max 200), max_bytes (default 30000), edge_kinds (default ["imports","calls"]).

mode: "reachability" selects full closure from entry points. granularity defaults to package (directory import edges) and accepts function (Go callable call edges). Package mode asks what a package main directory or topology_routes candidate pulls in; function mode asks what exact callable roots pull in. See Topology → Package-level reachability for scoping, root rules, shape details, and correctness limits. Inputs (reachability mode; roots/path_to/layers are rejected outside this mode, not silently ignored): granularity (package default | function), roots (package directories or file.go#Symbol, plus "main"; defaults to package-main and route candidates), path_to (a directory in package mode or file.go#Symbol in function mode — one root→target chain), and layers (boolean — SCC condensation instead of the summary). Package responses open with package-level (import edges, production imports only — Go _test.go importers excluded); function responses open with function-level (static call edges, Go only; production callers; lower bound). Both are capped at 4,800 bytes. Package mode is Go-only because it needs the Go package/import shape; function mode is admitted only for the Go call graph and refuses clearly when that admission is unavailable.

topology_affected

Given changed files/symbols, return likely affected files and tests. Inputs: files (array), symbols (array), max_results (default 50), detail (detailed default, includes individual test names for changed packages | compact package-only summary with test counts, reasons, and run_task targets).

topology_routes

Recovers entry points from registration sites in the topology index: route → handler for Go net/http, gorilla/mux, chi, gin and echo and for Python Flask/FastAPI decorators, plus the Cobra command tree (Use → Run/RunE, linked through AddCommand). A site counts only when its file imports the framework. Handlers are labelled resolved / same-package / decorated / name-match / ambiguous / external / unresolved — not type-checked; router group/mount prefixes are not composed. Swift, and frameworks with no recovered site, fall back to name/signature candidates labelled name-match. See Route recovery. Inputs: framework (optional: net/http | mux | chi | gin | echo | cobra | flask | fastapi | vapor | argument-parser), path_prefix (optional: a route prefix or a Cobra command path), limit (per list, default 50).

structural_query

Find symbols by shape, not name — a curated set of named structural checks over the topology index, complementing topology_search (by name) and search_in_files (by text). No raw tree-sitter S-expression queries are exposed (an LLM cannot reliably name per-grammar node types); the surface is a small vetted set. Inputs: query (required, one of undocumented-exports | long-functions | unused-context), language (optional filter), min_lines (long-functions threshold, default 80), limit (default 50). The checks:

  • undocumented-exports — exported functions/methods/types/constants with no doc comment (index-only; "exported" = leading-uppercase, or non-_-prefixed for Python).
  • long-functions — functions/methods spanning ≥ min_lines lines, longest first — decomposition candidates (index-only).
  • unused-context — Go functions taking a context.Context parameter whose body never references it (reads the body under the pinned workspace; skips grouped/anonymous params rather than false-flag). Results are source=topology (approximate). Returns a clear message when the index is disabled or empty.

context_for_task

Experimental, read-only context pack that starts from explicit seeds. It is unpinned and outside the lean profile: discover it by name (a client that hides it should say it is unavailable, not that plumb lacks it). At least one file or symbol is required; task prose only re-ranks and never seeds, and prose alone is refused with a handoff to workspace_search. It runs no LLM, no network call and no mutation, and never attaches or moves the connection's workspace pin: relative paths resolve against the calling agent's pinned workspace, and an unpinned call is refused with a session_start handoff. Each agent is answered from the topology index and the memories of the root it is pinned to, never from another agent's.

Inputs: files (files only; a directory is scope, not a seed, and is refused), symbols (code selectors: path#Recv.Method or a bare selector; path#Selector after a document path such as a .md file is refused, because documents come through corpora), intent (understand (default) | change), task, within (paths or globs that narrow the pack and never widen it: a literal selects itself and everything beneath it, a glob with / is anchored at the root, a glob without / matches one path segment anywhere, a leading **/ matches from any depth; an entry outside the workspace is refused), corpora (subset of code | docs | memory; default all permitted), max_bytes (default 12000, cap 32000; a larger value is clamped and the header says so; a value below 1536 is refused) and have ([{symbol, content_sha256}], bodies you still hold; see Acknowledgements below).

Limits. Up to 8 seeds (further ones are reported unresolved); a walk of two hops and 60 nodes under a 2 s deadline, with the scope filter (within, corpora, the root, .plumb/) applied at every hop; complete bodies for the seeds and for the 8 best-ranked related declarations, as the budget allows; source reads of at most 4 × max_bytes bytes in all, shared by the bodies, the memories and the documents (the sibling files read for same-package callers, at most 4 MiB, are parsed and not delivered); at most 3 memories (out of at most 200 memory files scanned) and 4 document sections, each quoted at most 200 bytes; up to 16 acknowledgements; and the language-server step below. Every cut is stated in the pack, with its count.

max_bytes bounds the whole served response: the pack is rendered into max_bytes minus a 1024-byte reserve for the text the connection layer appends (mailbox preview, policy notes), and that appended text is itself held to the reserve (a long message preview is shortened, or becomes a one-line pointer at check_messages). When the pack does not fit, the least important lines are dropped first, a line is never cut in half, and a final line reports the exact count omitted by class.

Output. A header (tool, root, intent, budget), then seeds, each symbol seed followed by its body; related declarations, ranked, the best few with their body; gap candidates; affected tests; constraints; gaps; and next calls. A selector that matches several declarations is ambiguous: the candidates are listed (at most five, as path#Selector ready to paste back) and none is chosen. A selector that matches nothing is reported as unresolved and nothing is invented; candidates shown under it are labelled and are never seeds. A file in a language the index has no extractor for is accepted as a seed and labelled as a coverage gap: its symbols are unknown, not absent.

Bodies and edit guards. A body is the declaration's exact lines, sliced from one snapshot of the file, with its content_sha256 (the SHA-256 of exactly the delivered text, without the line-number gutter). Once per file, a separate guard line carries the file's mtime and SHA-256 in the shape read_symbol prints, so edit_file's expected_mtime and expected_sha can be copied; a content hash is never an edit guard. The index's line span is used only when the index had parsed the very bytes of the snapshot when the span was read; otherwise the declaration is found again in the snapshot, and if it cannot be found exactly once the body is withheld with the reason. A body is never split: it degrades to a status line with the signature, then to a counted omission, and one larger than the whole budget becomes a read_symbol handoff naming its size and hash. Only a body that was actually delivered is recorded as a read, once per file and per agent, so it satisfies strict mode's read-before-edit check and nothing else does. A body in a file matching [history] sensitive_globs that the walk reached (rather than the caller named) is reduced to its location and a label.

Evidence labels. Every related declaration carries the class of the relationship that reached it, and the pack never presents a weaker class as a stronger one: e4 a call the language server confirmed (call hierarchy); e3 an extractor edge (a containment, a same-file call, an import); e2 a derived edge from the call or import resolver, followed only around a subject the call-graph admission rule accepts; e1 a heuristic edge (Python's intra-file calls, with confidence 0.8) or a same-package name match (below); e0 a gap candidate. The Go call graph is syntactic, so a method's callers are shown as the resolved callers plus gap candidates (the declarations of files that import the method's package), marked "not a resolved caller"; an empty, short or cut caller list is never reported as "no callers", and a walk that was cut says so before it says anything about the index.

Caller completeness (Go). The index keeps the call resolver's outcome for every recorded call, so for each Go function or method seed, and each top declaration of a file seed, the pack states its callers against the index rather than the walk: callers of X: complete (every recorded call of X resolves): N in the index, M listed here when no call of the name is left open, or N in the index, M listed here; K unresolved call(s) of X may also be it: a.go:12 (receiver), … naming the open calls that could be this declaration (a receiver whose type no declaration states, a bare name in the same package or in a file that dot-imports one, a name the calling function also declares in some block, a same-file call where the file declares the name twice, a call outside any function), so the caller checks those sites rather than the whole tree. Calls only: a function used as a value, or a method called through an interface, is not a call of it, and the pack says so once. Checked against the Go type checker's static callers on this repository and on golang.org/x/tools, no "complete" claim missed a caller.

Same-package callers. The Go call resolver links bare same-package calls, calls on a method's own receiver and calls on a typed parameter, so those callers arrive as resolved e2 edges. A test caller is one of them, and at most 12 test callers are listed per declaration (the pack counts the rest; the affected-tests section counts their packages). A method called on a local variable or a field is still not an edge, so for each callable method seed, and each one one hop out, the pack reads the call sites the index recorded in the files of the same directory and package clause, and lists the declaration holding a call of the seed's name as a caller at e1, labelled "same-package name match" (and "test" for a _test.go file) and never counted among the resolved callers. A function gets this search only on an index built before the resolver linked same-package calls; there its match is a bare call that no local declaration of the caller shadows. A method's match needs receiver evidence in the caller itself (its receiver, a parameter, var x T, x := T{}, &T{} or new(T)). A call whose receiver type is not evident, a name declared twice in the package, and a file withheld as sensitive are e0 gap candidates only. A sibling file is parsed only if it still hashes to what the index parsed, the scope filter runs before it is read, and a sensitive file is never read. At most 12 are listed per declaration (the pack says how many more there were), 64 call sites are examined per declaration, production code before tests, and a hint searches none.

Freshness components. The gaps section labels three things apart, so one cannot be mistaken for another. The index: a missing, failing or foreign index makes no relationship or test-impact claim at all. The files: a file that changed since it was indexed has its bodies sliced from the current snapshot, its relationships marked possibly stale, and the change stated. The language server: the first three callable symbol seeds are also put to the language server (call hierarchy) under an 800 ms sub-deadline inside the 2 s total; what it confirms of an edge the index drew, and what only it can see (typically a receiver-method caller), is e4, and the line says how many seeds it answered for. If the server is absent, still warming, in error or too slow, the pack is the structural one and the line says structural-only partial result; LSP enrichment unavailable with the cause; it never waits past the sub-deadline and never fails the call. With no callable symbol seed the server is not asked.

Constraints. Memories of the calling agent's root whose paths:, source_symbols: or source_paths: frontmatter ties them to the seeds, and document sections scored on their own text, are listed as evidence, not instructions, each with its corpus, canonical root and path. A path matching [history] sensitive_globs is named and never read.

Acknowledgements (have). A {symbol, content_sha256} entry whose hash is the exact content_sha256 of the body the pack would deliver for that declaration replaces the body with an "unchanged, still held" line carrying its current location. A file's hash, a hash of a line range or a hash borrowed for another declaration is not that body and leaves it to be delivered. An acknowledgement is not a read: it records nothing and prints no edit guard.

Hint. The collector also answers a smaller hint for advisory lifecycle hooks: selectors, locations and evidence classes from the index alone, with no bodies, no reads, no memories, documents or mail, and no language server. An ambiguous or unresolved selector is a gap, never a list of candidates; scope, root and sensitive paths apply at every hop; and the answer is bounded by bytes and by a deadline, returning a partial with a gap rather than a late answer.


VCS & utilities

git

Unified tiered git tool. Read subcommands always run (status, log, diff, show, blame, shortlog, check-ignore, merge-tree, check-attr, cherry, ls-files, grep, and branch/tag/stash listing). Because the read tier is always on, it refuses the options on those verbs that do something other than read this repository. --output (on diff, log, show, shortlog, blame and stash show) writes a file. grep -O/--open-files-in-pager runs a program. --no-index (on diff and grep), blame --contents, blame -S or --ignore-revs-file, grep -f, ls-files -X/--exclude-from and the diff options' -O<orderfile> read files outside the repository. Beyond those, a separate argument, a long option's =value or anything after -- that names an existing path outside the repository is refused — even a search pattern such as /usr. Write needs [git] allow_writes (add via files, commit via message, switch, merge, branch/tag create, stash push/pop). Destructive (reset, clean, checkout, restore, rebase, revert, cherry-pick, …) needs allow_destructive + confirm:true. Network (push, fetch, pull) needs allow_push + confirm:true. Force-push to a protected branch and ad-hoc URL pushes are always refused. See Configuration → [git]. The plumb-git skill covers the tiers from the caller's side, including the narrower plumb tool to prefer over a destructive git command (undo_edit, file_status, minimal_diff_review).

Ambiguous subcommands are classified by their arguments, biased towards the safer-to-deny higher tier, and read the way git's own parser reads them: abbreviated long options (--disc is --discard-changes), bundled short flags (-dr) and option values (tag -m -d is a message) all count as git counts them.

  • checkout -b (branch creation) is write; any other checkout is destructive (it can discard the working tree or detach HEAD), including -b with -f. -B <name> is write when <name> is a new branch and destructive when it resets an existing one (a new branch means a plain name given once; see the last bullet below). Prefer switch for safe branch changes.
  • switch is write, but switch -f/--force/--discard-changes is destructive, and so is -C/--force-create on a branch that already exists (on a new one it is a write).
  • restore --staged (index only) is write; restore --worktree (or no flag) is destructive.
  • branch: creating, renaming (-m) or copying (--copy) is write, as are the upstream and description options. Every forced form is destructive whether or not the branch exists: -f/--force, -M (--move --force), -C (--copy --force, also inside a bundle such as -qC) and -D/-d/--delete. List mode (--list/-a/-r/--contains/…, or no arguments) is read, and a later --no-list cancels it. -v alone does not list once a name is given.
  • tag: creating is write; --delete/-d is destructive, and so is -f/--force when the tag already exists (on a new one it is a write); list mode is read.
  • --end-of-options ends option parsing exactly as -- does.
  • A checkout -B, switch -C or tag -f call is lowered to write only when the tool can show it creates a ref; branch is never lowered, because its option grammar defeated every attempt to tell a creation from a reset. The option appears exactly once, however it is spelled, because git keeps the last of a repeated -B. The name is plain: ASCII letters, digits and . _ - /, with no leading -, no @ or {, no .., and not ending in .lock or /, because git expands @{-1}, @{u}, @{push} and <branch>@{upstream} to an existing branch. git check-ref-format accepts the name and prints it back unchanged. And git, asked in the target repository just before the call, says the ref does not exist. Any other call, including one git cannot answer, is destructive.
  • stash: bare git stash, push, pop, apply, save, create, store are write; list/show are read; drop/clear are destructive; an unknown stash sub-subcommand is rejected with the valid list.
  • merge (--no-ff, --ff-only, --no-edit, -m, a ref) is write and runs pre-merge-commit/commit-msg as commit runs its hooks; --abort and --quit (also abbreviated, as git expands them) are destructive. --continue, --no-verify, -e/--edit and -F/--file are refused — conclude a merge with commit and a message. A merge that stops on conflicts fails naming the conflicted files and leaves git's merging state (MERGE_HEAD) to resolve, add, and commit.

A file plumb wrote this session and then changed on disk through this tool (a switch, merge, restore, stash pop, reset, pull, …) is re-recorded afterwards, so the next read_file does not report it as a peer's edit. A file a peer had already changed before the operation, or changes after it, still warns. When a tier is off because this workspace's untrusted project config asked for it, the refusal says so and names the plumb trust command for the path.

add and commit are typed, not pass-through: commit only ever runs commit -m <message>, plus -- <files> when files is passed to limit the commit to those paths (so --amend, --no-verify, -F, and the editor are unreachable) and add only runs add -A -- <files> (no globs, no free-form paths; -A stages deletions of the named paths, which is why removing a tracked file needs no git rm). Pre-commit hooks always run. Every non-read call consumes one write-rate-limit slot. Output is capped (200 lines for log/blame, 100 KiB overall); add and commit return a concise summary (staged file count, or <short-hash> <subject>) rather than raw git output.

Slow writes do not outlive the call. A write- or destructive-tier call still waiting after [git] detach_after (default 45 s, below the usual MCP client call timeout) returns a success result saying the operation is still running in the background — pid, start time, HEAD at that moment — instead of blocking until the client gives up while the commit lands anyway. The child is not killed. Until it finishes, further non-read calls on that repository are refused (reads still run, with a note); afterwards the next call from each session reports landed as <sha> or the failure with git's output. See Configuration → When the call stops waiting.

No git child opens an editor or a credential prompt: each runs with GIT_EDITOR=true, GIT_SEQUENCE_EDITOR=true and GIT_TERMINAL_PROMPT=0, so rebase --continue, cherry-pick -e and revert --edit keep the message git prepared and rebase -i runs its todo list unchanged. A value under [git] env wins (see configuration.md).

Attribution: with [git] commit_trailer = true (default off) every plumb-mediated commit is stamped with a Plumb-Session: <session-name> trailer naming the agent that made the call (on a connection shared by several agents, each has its own session name, and an agent with none is stamped with nothing rather than with another agent's); regardless of that knob, workspace_sessions always lists recent commits per session (short SHA, subject, repository) from its recent-writes feed. See Configuration → [git].

With [collab] intents = true, a repo-state op — every destructive-tier op, plus the write-tier HEAD movers commit/switch/checkout/merge — also surfaces any live peer share_intent claims covering the repository as an advisory # plumb-warning: block naming the peer and the claim.

Coverage is tier-aware. A destructive op can clobber anything in the repository, so any live claim whose paths lie inside it counts. A write-tier op (commit/switch) warns only for a claim that is genuinely repo-wide — an unscoped broadcast, or globs such as ** that cover the repository root — so a narrowly scoped claim like site/** no longer fires on every commit.

Informational only: it never blocks the op, never requires confirm, and index-only writes (add, restore --staged) stay silent.

Inputs: subcommand (required), args (array), files (array, for add), message (string, for commit), confirm (bool).

git_init

Initialise a git repository at a path. Inputs: path, init_plumb (bool — also creates .plumb/context.md).

find_replace

Text/regex find-and-replace across files; dry-run by default. Inputs: pattern, replacement (required), path, glob, use_regex, dry_run (default true), dirty_ok, format_after (run the workspace formatter), case_sensitive, max_files, max_file_bytes. For identifier refactors use rename_symbol (scope- and type-aware); find_replace is the plain-text lane. When [edits].show_write_diff is on (default), the response appends a per-file unified diff in both preview and applied modes, for up to the first 20 changed files, with a +N more file(s) summary beyond that.

file_diff

Unified diff between two files (system diff -U). Inputs: two file paths.

write_history

Reviews file write history recorded across plumb write tools. Supports listing recent changes with session, agent, tool, and file filters, and showing full diffs and tool-call metadata by sequence number or call ID. Withheld content (sensitive, binary, too large, overflow) displays markers rather than file content, and a recorded diff is re-checked against the current sensitive globs before it is shown. A call's diffs share a 128 KiB budget, and its whole response is capped at 192 KiB. Inputs: mode (list (default) or show), seq (integer, show mode), call_id (string, show mode), session (string, or "self" for the caller's own writes; on a shared connection, only this agent's, which excludes rows written before the connection became shared), agent (string), tool (string), file (string), limit (integer: default 20 when listing; for a call_id, default all; max 100), offset (integer, default 0), max_diff_bytes (integer, default 32768), workspace (string, optional).

minimal_diff_review

Advisory review of a git diff for signs of over-building — findings never block a write, they are hints. Deterministic (no LLM): it flags a single-use abstraction, a thin forwarding wrapper, a new dependency with a well-known stdlib equivalent, a possible duplicate helper, and a logic change with no accompanying test change. Evidence is asymmetric — a check stays silent unless it can point at concrete evidence and (where defensible) a smaller alternative, so silence is not proof a change is minimal. Every finding is confidence-labelled: high = proven from the diff text; low = leans on the topology index, which is approximate (its call graph is intra-file — unlike find_references' exact cross-file lookup — and may be a few edits stale). Reviews the working-tree diff vs base_ref; scope it with files in a shared worktree so unrelated peer-agent edits are excluded. Degrades cleanly outside a git repository, and each response carries a not analysed / limits section listing the review's blind spots. Inputs: base_ref (default HEAD), files (array, optional scope), mode (changed (default) = working tree vs base_ref | staged = index vs base_ref), max_findings (default 20, max 100), include_suggestions (default true).

run_task

Run a stored per-language [tasks.<lang>] command — no shell, bounded output (100 KiB/200 lines) and timeout — from the workspace root, or from the task's working_dir when [tasks.<lang>] working_dir sets one. Inputs: slot (build/lint/test/e2e/verify; verify runs build then test), target (optional, fills a {target} placeholder; one shell-safe argument — it is one positional, in practice a package), run (optional test-name filter for a {run} placeholder — go test -run, pytest -k, cargo's libtest filter — e.g. TestA|TestB), verbose (optional, fills a {verbose:<flag>} placeholder, e.g. go test -v to see skipped tests), language (optional, see below), env and unset (optional, per-call environment changes — see below). The shipped go/python test defaults carry all three placeholders (rust: {target} and {run}); a run on a command without {run} is refused, an unplaceable verbose is noted — see configuration.md. Commands run with the language's [tasks.<lang>] env, which the response lists (env: GOTMPDIR=…, credentials redacted). A project-supplied command must be trusted first (plumb trust); defaults and global-config commands always run. Pairs with topology_affected (which says which tests to run) — the plumb-testing skill walks the whole post-edit loop.

env and unset change the environment, never the command. env sets variables for that call only ({"MODE": "experiment"}; {workspace} and {working_dir} expand exactly as they do in [tasks.<lang>] env), and unset removes names from it — an inherited entry and a configured one alike, so unset: ["GOTMPDIR"] runs the stored command under the system temporary directory instead of the checkout-local one the project mirrors CI with. The response lists both (env: …, unset: …), and says so when a value replaced a configured entry or removed one. A name that decides which program or code the stored command runs (PATH, GOFLAGS, GOWORK, GIT_*, NODE_OPTIONS, MAKEFLAGS, LD_PRELOAD, …) is refused in BOTH directions — setting one replaces what runs, and removing a configured GOFLAGS=-mod=vendor or GOWORK=off changes what the command builds just as surely. A project that needs such a name writes it into [tasks.<lang>] env, where plumb trust lists it and flags it. That list is a guard rail rather than a boundary — make reads any variable, so no enumeration of "names that steer execution" is ever complete — and what run_task actually promises is the argv: the stored command, with no shell and no agent-supplied command line. See configuration.md.

Polyglot workspaces. Without language, resolution uses the workspace's primary — the one session_start reports. A repo with Python and TypeScript has only one primary, so the other language's commands, including the shipped defaults, used to be unreachable through this tool. Pass language: "python" to run [tasks.python] regardless of what the primary is. Eligibility is whether [tasks.<lang>] configures anything, not whether that language's server is installed or was detected, so the defaults stay reachable on exactly the repos this exists for. A language with no configured commands is refused, naming the ones that have them — never silently resolved against the primary, which would run a different language's tests and report them as yours. The plumb build/test/lint CLI subcommands remain primary-only.

Two things run_task does to your call, and says so in the response. A stored command that is plumb's own default with the {target:<D>} placeholder written out as D — [tasks.go] test = "go test ./..." — has the placeholder restored so a scoped call works instead of being refused forever. It is an exact equivalence, not a guess (both spellings build the same argv unscoped), it fires only on that exact match, and the response names the command it rewrote; see configuration.md. And a composite slot (verify) has no single command for a target to land in, so a target passed to one is accepted, not applied, and the response says so and names the sub-slot to call instead — it runs the full suite, and never reports that as a scoped run.

run_command

Run a named entry from the workspace's [[command]] allow-list under an OS sandbox. Inputs: name (the allow-list entry), target (optional, fills the entry's single {target} token with one shell-safe argument ([A-Za-z0-9._/:@-]); refused if the entry has no {target}). It runs only the exact fixed argv the user configured — no shell, no agent-supplied command line. An entry from a project's .plumb/config.toml must be trusted first (plumb trust); a global-config entry always runs. Output and runtime are bounded. For an ordinary build/lint/test, prefer run_task — its [tasks.<lang>] slots ship with defaults.

The sandbox is integrity-only: it confines writes, not reads or environment. Each entry sets its own deny_network, so the response reports network=on/off; an entry with require_sandbox is refused outright rather than run unsandboxed when no OS sandbox is available.

There is no ad-hoc-shell tool. run_command and run_task are plumb's only two execution surfaces — plumb registers no sh -c equivalent, so anything an agent can run is something a human first wrote into config. See the [[command]] / [commands] section of configuration.md and threat-model.md.

mutation_test

Verify that tests actually assert what they appear to: apply an explicit mutant, prove it still compiles, run a scoped test set, classify the result, and restore the file. Inputs: mutants (array, 1–20, each {file_path, old_string, new_string, label?} — an exact-once str_replace in the style of edit_file; it does not generate mutants), test_task (slot, default test), test_target (fills the stored test command's {target} — the way to scope the run; ask topology_affected what to name), test_run (fills the test command's {run} test-name filter, as run_task's run does, so each mutant runs only the tests that should kill it — seconds instead of a whole package), compile_task (slot, default build), timeout_seconds (per step, default 600).

Three outcomes. killed — the mutant compiled and a test failed, so the assertion is real. survived — the mutant compiled and every test still passed, so the assertions covering that line are vacuous; this is the finding that matters. invalid — the mutant did not apply, did not compile, or the run timed out, or a command could not be started; it proves nothing and is never reported as a kill. That last distinction is the tool's reason to exist: a mutant that does not compile, a sed that silently matched nothing, or a test runner that is not installed all make the test command fail, and a hand-rolled harness reads any of those failures as a kill.

The compile gate cannot be disabled — a workspace with no build command configured is refused rather than served unverifiable verdicts. The gate always runs unscoped, because a whole-module compile catches breakage a package-scoped test never reaches.

The workspace must be green before the run starts. A kill means "passed before this change, failed after it", so both halves have to be checked: the compile and test commands are run once on the unmutated tree, and the whole run is refused if either fails. Otherwise a suite that was already red — a peer's edit elsewhere in the tree, a pre-existing failure, a missing test dependency — reports every mutant killed for a reason that has nothing to do with any mutant. The dirty-file refusal below does not cover this: it guards the file being mutated, not the rest of the workspace. The cost is one extra compile+test cycle per run, not per mutant; scope it with test_target.

The commands run in the work-tree that holds the mutated file. The stored compile and test commands are resolved for the connection's workspace and run from its directory, so a mutant in a git worktree of the same repository — the recommended isolation setup, usually a directory inside the workspace — used to be tested against the main checkout: every mutant read survived with compile ok and tests ok, which is indistinguishable from vacuous assertions, and worse than no verdict. plumb now asks git which work-tree holds each file, and which holds the commands' directory, and compares git's answers (never two spellings of a path). A file in a submodule is placed by its outermost superproject: the submodule checked out inside a worktree is that worktree's copy, and re-roots like any other file in it. Per command:

  • every file is in the commands' own work-tree, in no repository, or in the main work-tree of a different repository (a submodule, a sibling module a go.work or make -C reaches): nothing changes — those files have no other copy for the commands to reach instead, and a file in no repository still gets the "no git safety net" warning;
  • every file is in one other work-tree of the commands' repository (a worktree, or the main checkout when the session is in a worktree): the command runs from the same relative directory there, and the report says ran in: … re-rooted from …; the baseline runs there too and names that directory if it is red.

Anything else is refused before anything runs, mutates, or spends the write budget, with the file, the tree the commands would have run in, and the reason:

  • the mutants need more than one work-tree (one call per tree);
  • a file is in a linked worktree the commands cannot be moved into — of a different repository, or when their directory is in no repository — because that worktree's twin is what the commands would reach;
  • the destination has no directory at the same relative path, or reaches it through a symlink that leads somewhere else;
  • a command argument holds an absolute path into the tree being left (compared by path component after resolving links; relative arguments move with the directory). Best effort by construction: a path assembled inside a script cannot be seen.

When git cannot answer (a checkout owned by another user without a safe.directory entry, or no git on the daemon's PATH), the .git links on disk are read instead: a run that stays where it is proceeds as it always did, and one that would need moving is refused with git's own message. A worktree usually sits under a go.work that lists the main checkout and not the worktree; every task command (re-rooted or not, run_task included) gets GOWORK=off there by the rule in configuration.md, the report says so, and a GOWORK you set is never overridden.

Restoration is guaranteed on every exit path (pass, fail, compile error, timeout, panic, cancellation): the pre-mutation bytes are snapshotted in memory, rewritten under the same per-path lock the write tools use, and verified by SHA-256 before the run reports clean. A file with uncommitted changes is refused with no override — a clean file is what makes git checkout a guaranteed recovery if the daemon dies mid-run. One run at a time per daemon; a second call is refused rather than queued, since concurrent runs would read each other's breakage as their own result. The refusal names the run in the way: its session name and id, workspace, how long ago it started, and its progress (checking its mutants, the unmutated baseline, or mutant k of n and the step it is on). The slot stays per daemon, not per workspace: workspaces nest (a superproject and its submodule are two workspaces over one tree), so two of them can mutate and build the same files.

A run its caller abandons is cancelled (the file is restored as on any cancellation) and the slot released, rather than holding it for a report nobody can receive. That covers a client that cancels the call (notifications/cancelled, which Claude Code sends when the user interrupts a tool, keeping the connection) and a connection that closes. No progress is sent after a cancel, and the write-budget slots charged for mutants that never ran are given back.

A long run stays alive through progress. A client that asks for it (a progressToken, which Claude Code always sends) gets a notifications/progress at the start of each compile and test step, and again every minute while a step runs. Each one resets an idle-timeout client's window. Claude Code drops a call that is silent for 30 minutes (5 for an http/sse server), checking every 30 seconds, and sends no cancel when it does. A client that asks for no progress hears nothing until the report, so a run the baseline says would take longer than 25 minutes is refused before anything is mutated, with a batch size that fits. The baseline can understate the cost, because Go may serve the unmutated suite from its test cache, so the run is also stopped between mutants once their real cost shows it would pass the budget. The report covers the mutants that ran and says why the rest did not. The budget is fixed at 25 minutes. A client that asks for progress is never refused or stopped by it.

The test_run filter refuses a leading inline flag that go test -run would apply to part of the pattern only (see {run} in configuration): (?i)a|b is case-insensitive for a alone.

agent_config

Read and (when the user enabled [agent_config_writes]) write a small allowlist of config keys on the user's behalf. Inputs: op (describe/set), set (map of dotted key → value for op=set), scope (project only). Writable: the [tasks.<lang>] slots + log_level, ui.theme, ui.path_style, topology.exclude_patterns. Guardrails (git tiers, roots, strict mode, API keys, quality.bin, the enable knob itself) are never agent-writable, and quality.analysers was withdrawn because [quality] is read from the global config — a project-scope write to it could only ever be a no-op reported as success. A batch is validated and applied atomically, tagged provenance=agent, and revertible with plumb config unset.