lazy documentationv0.22

Surface asymmetries: CLI vs MCP

Lazy exposes two control surfaces over the same daemon: the CLI (src/cli/commands/*, dispatched from src/index.ts) and the MCP tool surface (src/mcp/tools.ts, 37 advertised tools). They are not, and should not be, mirror images.

This document lists the asymmetries that are deliberate. If you are about to file "the MCP surface is missing X" or "why does the CLI let me do Y but the tool doesn't", check here first — every entry below is a design decision, not a gap. A difference that is not listed here is most likely a real gap — see When you find an asymmetry that is not listed here.

The governing rule: tight MCP, lax CLI

A human at a terminal and an agent calling a tool make different mistakes. The human mistypes an id, then reads the output and notices. The agent confidently targets the wrong task, gets a plausible-looking result, and builds on it for another twenty turns.

So the MCP surface is deliberately narrower and more friction-heavy than the CLI. The CLI trusts its caller; MCP does not. When the two disagree, that is usually the rule at work rather than drift.

1. Agent-ownership gating

Most task-targeting MCP tools check the caller (the exceptions are listed below). ctx.taskId non-empty means a task agent; empty means the builder. The builder is unrestricted; a task agent may only act on its own task or a direct child.

The gates in src/mcp/tools.ts that implement this:

Gate Applies to Rule
assertAgentMayTarget show, unblock, ask, reject, close, stop, edit, reopen own task, or a direct child
gateAgentTarget diff, wait, submit, resume, sync — handlers that don't otherwise open storage own task, or a direct child, resolve-then-gate
assertAgentMayTargetChildOnly accept direct child only — an agent can never accept itself
(own check in the handler) start a direct child only — stricter than assertAgentMayTarget, so an agent cannot restart itself

Tools NOT in that table take whatever task_id they are given: the listings (lazy_list, lazy_blocked, lazy_active) by design — agent reads are open tree-wide, because learning from other agents' work is the point — and the annotation writes (lazy_comment, lazy_tag, lazy_untag, lazy_prioritize) not by design. The decision on record is that annotations belong on a direct child, and src/prompts/tool-instructions.md tells agents exactly that, but no gate enforces it yet. That is a known gap with a follow-up filed, not an asymmetry to preserve. lazy_journal is deliberately open: a journal entry never enters anyone's prompt and never triggers a turn.

Additional per-tool caller rules:

The CLI has none of this. A human running lazy accept is the review authority.

Why gating is per-handler, not per-tool-list. Every tool is advertised to both callers and each handler decides. Filtering the advertised list per caller would mean an agent asking "can I do X?" gets silence instead of a reason. A refusal that explains itself is a better teacher than an absent tool.

2. Two-step confirmation codes (MCP-only friction)

lazy_accept, lazy_reject, lazy_close, lazy_reopen, lazy_redo and lazy_create (when creating under main while an active task exists) implement a two-step protocol (src/mcp/confirmation.ts): the first call returns guidance plus a confirmation_code, and only a second call carrying that code executes.

This is the MCP analogue of the CLI's interactive prompt and --yes. It is not a straight port: the CLI prompt can be skipped with a flag the caller controls, whereas the MCP code must be echoed back from a prior response, which no single hallucinated call can produce. lazy_accept skips the two-step when the diff is tiny — friction proportional to blast radius.

Do not add --yes-style bypass parameters to these tools.

3. MCP never auto-starts work

lazy redo starts the replacement task by default (--no-start opts out). lazy_redo never starts it; the caller must call lazy_start separately. Same for lazy_clone / lazy_create on both surfaces.

Rationale: starting a task spends money and holds an agent slot. One tool call should not fan out into a running agent the caller didn't explicitly ask for. Two calls is the right amount of friction.

For lazy_redo the gap does a second job: the replacement is created under the SAME parent as the old task, and its branch is cut from that parent's HEAD only when it starts. The pause between the two calls is the caller's window to re-parent the replacement (lazy_reparent, or lazy_edit with parent="" for top-level) before that branch exists. The tool description says so explicitly — an earlier version claimed the replacement started "from current main", and a caller who believed it lost a task's worth of work to a branch cut from the inherited parent's release branch.

4. Human-only commands with no MCP equivalent

5. Read-only turns

LAZY_MCP_READ_ONLY serves a reduced toolset built from READ_ONLY_TOOL_NAMES (src/mcp/tool-access.ts) so reflective/contemplative turns — including lazy_ask targets — cannot mutate state. lazy_ask is itself classified write even though the asking is read-only, because it launches an agent turn on another task. TOOL_ACCESS is hand-maintained on purpose: whether a tool mutates is a judgement about effects, not something derivable from a schema.

There is no CLI counterpart; a human deciding to only look at things needs no enforcement.

6. lazy_internal_git is validated but never advertised

allDispatchableTools() = allTools + internalGitTool. The internal tool is reachable over HTTP and therefore must be schema-validated, but it never appears in any advertised tool list. Coverage and discoverability are different questions (src/mcp/tool-registry.ts states this as an invariant).

7. Presentation flags have no MCP analogue

--json, --tree, --flat, --ids-only, --follow, --group, --full and friends shape terminal output. MCP tools return structured JSON already, so these are not gaps. --follow in particular is a streaming terminal affordance; the MCP equivalent is polling lazy_wait.

The corollary: an MCP tool returning a differently shaped object than the CLI's --json is drift worth fixing, but an MCP tool having no tree parameter is not.

8. Unblock always carries feedback; resume is the no-feedback path

Both surfaces refuse an empty unblock: lazy_unblock's schema sets feedback minLength: 1, and lazy unblock rejects a blank or missing --message. That is deliberate — unblock exists to deliver human guidance, and a silent one reads as guidance that got lost. Resuming a task without new feedback is lazy_resume / lazy resume, which is why neither is deprecated.

Keeping that pair honest is mechanical: test/unit/mcp-resume-advice-honesty.test.ts fails if any tool description, the builder prompt, or the CLI notice tells anyone to unblock with empty feedback.

9. start creates nothing — on either surface

Neither lazy start nor lazy_start accepts creation parameters. There is no --goal, --prompt, --code, --type or --parent on lazy start, and lazy_start's schema takes task_id plus run-time overrides only. The only way to make a task is lazy create / lazy_create, then start the id it returns.

This is the one entry here that is a shared restriction rather than a CLI-vs-MCP asymmetry, and it is listed for the same reason as the rest: it looks like a gap and keeps getting filed as one.

lazy start did have an inline create mode. It was removed deliberately in March 2026 (remove-start-inline-create, commit a709663), which dropped --goal, --prompt, --code, --type and --parent from the CLI and cut lazy_start's schema down to task_id in the same change.

Rationale: a task created inline is created and started in one irreversible step, so any parameter the caller forgot is unfixable by the time anyone notices — most damagingly --parent, which strands the task on the default branch with a running agent already on it. Wrong parenting was the single largest source of rework in the project, and agents in particular had a documented habit of starting a task first and trying to reparent it after. The create-then-start split makes creation cheap, reviewable and correctable: lazy edit can still fix a backlog task's goal, prompt, code, type and parent, and none of that is available once the agent is running.

So the fix for "I want a task code on a task I started" is lazy create --code <code> ... && lazy start <code>, not a --code flag on start. Re-adding any creation parameter to either start surface reverses this decision and needs the engineer's explicit approval.

10. Asking a conversation: one polymorphic CLI id, a separate MCP tool

lazy ask <id> resolves its id the way lazy show does: a task id asks that task's live agent, a stored conversation's session id (or a unique prefix) asks a throwaway read-only agent that reads the transcript. MCP does not overload lazy_ask the same way — a conversation ask is lazy_conversation_ask.

The reason is the ownership gate. lazy_ask runs assertAgentMayTarget: a task agent may only ask its own task or a direct child. A stored conversation has no owner, no parent and no place in the task tree, so a polymorphic lazy_ask would have to skip that gate for some values of task_id and enforce it for others — exactly the "plausible-looking result on the wrong target" failure the gate exists to prevent. A separate tool keeps each one's contract single.

Both surfaces share src/conversation/ask.ts, including id resolution, so they cannot drift into resolving the same string differently.

lazy_conversation_ask is classified 'write' in src/mcp/tool-access.ts despite persisting nothing. 'read' there means "cannot change task state, worktree contents, or launch an agent" — the ask launches one. Classified by effect, not by what it stores. One consequence worth knowing: a read-only turn (§5) serves read tools only, so an asked agent cannot ask a conversation. That is the intended reading of "read-only" — an ask turn does not spawn more agents.

11. The task's agent: in every MCP listing, in no CLI list table

lazy_show, lazy_status, lazy_list, lazy_blocked and lazy_active all return the task's agent alongside its model. The CLI's lazy list / lazy active tables do not have an AGENT column — on the CLI it is on the detail surface (lazy show's Agent: row) and in the web task list, where it is also a sortable column.

The reason is width, and it is the one asymmetry here that is about the medium rather than about trust. A JSON payload has no columns to run out of; the CLI tree table already spends 112 characters on fixed columns before the goal starts, so a 12-wide AGENT column pushed the goal — the column a human actually scans — off a 120-column terminal entirely, and off a 100-column one twice over. The MCP listings pay nothing for the same field, and a builder scanning twenty tasks for "which of these ran under cursor?" is exactly who needs it.

Per-TURN agent/model/effort labels are NOT asymmetric: lazy show, the lazy review TUI, the web task page and the MCP lazy_show turn payload all carry them, from one shared formatter (src/utils/turn-labels.ts).

12. Depth scoping: --levels on three CLI listings, on two MCP tools

lazy list, lazy active and lazy blocked all take --levels <n> to show only the first N levels of the hierarchy. On MCP, lazy_list and lazy_active take the matching levels parameter — lazy_blocked deliberately does not.

The reason is what each surface can render. Depth is only meaningful against a visible parent/child structure: the CLI's three listings all draw a tree, and an elided subtree shows up as (+N hidden) on the parent row that survived. The MCP lazy_list and lazy_active payloads carry parent_task_id, so a client can reconstruct that same shape and read hidden_descendants on the right task. lazy_blocked returns a flat array with no parent field at all — a depth limit there would silently drop blocked tasks with nothing in the payload explaining which ones or why, which is exactly the truncation the feature is designed not to do. Giving lazy_blocked a parent_task_id first would make levels a straightforward addition; until then the omission is the honest surface.

13. Un-approving a protected file lives only on the review page

Approving a protected-file violation is sticky: an unblock decides the PENDING violations only, so a record already marked approved survives every later call that says nothing about it. Both reviewer-facing surfaces can approve — lazy unblock --approve-file, lazy_unblock's approved_files — and neither can un-approve. There is no flag, no sentinel value, and deliberately none planned. The one write path is setViolationDecision in src/daemon/review-service.ts, reached from the web review page.

Three reasons this is deliberate rather than a missing flag:

The remedy is named wherever stickiness is documented — lazy unblock --help, the approved_files MCP schema, and lazy-toml.md: un-approve on the review page, or ask the agent to revert the file in the feedback text. If a reviewer ever needs this without a browser, it should be scoped as its own task rather than bolted onto an approval flag.

When you find an asymmetry that is not listed here

It is probably accidental — this page is the complete list of the deliberate ones. Treat an unlisted difference as a gap and report it. Adding a deliberate asymmetry means adding it to this file in the same change.