#16582

hooks: complete the Cursor adapter for all 21 events

Draftvorporealwants to merge intofrom

Description

Summary

Third PR of the agent hooks Phase 1 stack. It turns PR 2's beforeShellExecution-only parser into the complete Cursor adapter: every option in a hooks.json, all 21 event names, a binding with capabilities and failure semantics for each definition, matcher semantics, the stdin payload for every event Warp fires in Phase 1, and output decoding with the exit-code, malformed-output, and empty-output rules. Events Warp does not fire are parsed, reported, and never fired. This is ai-only code with checked-in fixtures; nothing in the loop changes.

  • events.rs: CURSOR_EVENTS, a static table with one entry per event: its name as hooks.json spells it, the Warp boundary it projects onto (or none for the two Tab hooks and workspaceOpen), whether Warp fires it in Phase 1, what its matcher is tested against, which output fields Cursor documents, the effect channels those feed, and its control effect: a permission decision (carrying the noun Warp's denial reasons use, whether user_message shows only on a deny, and what ask means on the event), a prompt halt, or observe only. The malformed-output and empty-output policies follow from the control effect. Everything else in the adapter reads an event's facts from this table and matches on the event only to pick per-event code. declare_bindings turns parsed definitions into DeclaredBindings in table order, then array order.
  • config.rs: the whole file shape. version must be 1; command, type, timeout, matcher, failClosed, loop_limit, prompt, and model are read; unknown event names and unknown definition fields are warnings; a prompt hook or a matcher that does not compile skips that one definition; loop_limit is capped at Warp's host cap of 8. Each matcher is compiled here, once.
  • sources.rs: what the adapter keeps about each source between parse and bindings: the parsed definitions, the document they came from, and their compiled matchers by pattern text. bindings reuses the parse when it is handed the same document; matching looks regexes up here and compiles only for a binding that was not declared from a parse.
  • matcher.rs: Rust regex, unanchored, case-sensitive, "" and "*" match everything. The value matched depends on the event (table below).
  • facets.rs: the shell and MCP views of a tool payload that both matching and encoding need (the pending command, a finished command with its output and exit code, an MCP call with its result).
  • encode/: mod.rs has the common fields every Cursor hook receives, then the event's own fields, for the twelve Phase 1 events. tool_input.rs is a JSON view of every AIAgentActionType; tool_output.rs is a kind-tagged view of every AIAgentActionResultType, sent as a JSON string.
  • decode.rs: exit 2, the per-event output fields, Claude's nested output shape where Cursor translates it, malformed and empty output, the failure policy, cancellation, and the strict hook policy.
  • fixtures/: full_config.json, invalid_matcher.json, unknown_event.json, prompt_hook.json, claude_shape_output.json, and one directory per Phase 1 event with hooks.json, the stdin.json Warp encodes for a fixed envelope, and a documented stdout.json. fixtures/README.md names the research/01 section each comes from.

Amendments to PR 1 (details under Departures): HookFormatAdapter::decode_output takes a DecodePolicy (the strict hook policy setting), DeclaredBinding carries loop_limit, and HookEffects carries unsupported, the effects a hook asked for that its event cannot produce.

Decisions

Four of these change what a hook can permit or block, and are also recorded in questions.md: only version: 1 is accepted; what ask means on each permission hook; where Claude-shaped output is read; and what the strict hook policy changes.

Event table and bindings

  • Merge order across events in one source (§6.3): bindings are declared in the order Cursor's reference lists the events, then array order. So for one shell command a preToolUse definition merges before a beforeShellExecution one. Cursor does not define their relative order (research/01 §2.3); a fixed order keeps scalar conflicts deterministic.

  • Events Warp does not fire yet (§4.1, §4.4): the six Phase 2 events (sessionEnd, subagentStart, subagentStop, beforeReadFile, afterFileEdit, preCompact) become bindings on their boundary whose matches always returns false, and each definition gets a parse warning ("Warp does not fire X yet; this hook will never run"). afterFileEdit keeps the tool.result boundary but is marked as not firing, since the boundary fires and the facet does not. The three events with no Warp moment (beforeTabFileRead, afterTabFileEdit, workspaceOpen) get a warning per definition and no binding, because DeclaredBinding needs a boundary and none exists.

  • Where the "unsupported in Phase 1" status lives: on CursorEventSpec::fires_in_phase_1, with the activation diagnostic produced as a parse warning on the source. The contract suggested a method on the binding; that would have been a second change to a shared type for something the adapter already knows, and the registry copies parse warnings into the snapshot's diagnostics today.

  • Capabilities per event (§4.4), from research/01 §2.4: the six permission hooks declare Verdict and UserNotice (preToolUse adds ReplacementInput); postToolUse declares ReplacementResult and ModelContext; postToolUseFailure ModelContext; sessionStart SessionEnv and ModelContext; beforeSubmitPrompt HaltTurn and UserNotice; stop and subagentStop Continuation; preCompact UserNotice; the observers nothing. The permission hooks do not declare ModelContext, because agent_message on a non-deny has no documented meaning (see Decoding), so validate_capabilities warns only for sessionStart, preToolUse, postToolUse, and postToolUseFailure, which is where Cursor documents context or a rewrite.

  • Per-event output rules (§6.4; research/01 §2.2, §2.4, §6.3, §7; research/05 §4). Malformed output and empty output follow from the control effect: Cursor says output that does not fit a permission hook's schema blocks and states no rule elsewhere, and silence is a missing decision only where the hook decides something. "Fail closed" is what failClosed: true, the strict hook policy, and a cancelled hook produce; on the events where it means nothing, failClosed: true changes nothing beyond the invocation log line ("Observers report failures and continue", "A failed stop hook does not continue the turn", "A failed session.start adds no context or environment").

    EventsMalformed outputEmpty outputFail closed meansaskuser_message on an allowClaude-shaped output
    beforeShellExecution, beforeMCPExecutiondenial, even when the definition fails opena missing decision; follows failCloseda denialhonored as Aska notice ("message shown in client")not read
    preToolUsedeniala missing decisiona denialno opinion with a log line; Ask under the strict hook policydropped with a log line (documented as shown only on a deny)read: hookSpecificOutput.permissionDecision, permissionDecisionReason, updatedInput stand in for the flat fields when those are absent
    subagentStartdeniala missing decisiona deniala denial with a reason saying so, under both policiesdropped (deny-only)not read
    beforeReadFile, beforeTabFileReaddeniala missing decisiona denialnot in Cursor's schema, so a schema mismatch (a denial)dropped on beforeReadFile (deny-only); not documented on beforeTabFileReadnot read
    beforeSubmitPromptfollows failCloseda missing decision; follows failCloseda halted promptnot applicablea notice when continue is truenot read
    stop, subagentStopfollows failClosedno opinion (the normal "no follow-up" answer)nothingnot applicablenot applicableread: decision: "block" with a reason, flat or nested, is a follow-up; any other decision is ignored
    sessionStart, postToolUse, postToolUseFailure, preCompact, and the observers (sessionEnd, afterShellExecution, afterMCPExecution, afterFileEdit, afterAgentResponse, afterAgentThought, afterTabFileEdit, workspaceOpen)follows failClosedno opinionnothingnot applicablepreCompact: a notice; sessionStart: accepted and ignored, as Cursor documents; elsewhere undocumentednot read
  • loop_limit (§4.5, §9.2; research/01 §1.1): read on stop and subagentStop; absent is Cursor's default of 5; null and values above 8 become 8 with a warning; on any other event it is ignored with a warning. The value is stored on the binding and passed through in ContinuationRequest.loop_limit; enforcement is PR 7.

  • Timeouts (§6.4): the default is the boundary's (30 s pre-execution, 60 s otherwise, 5 s for sessionEnd); timeout is seconds, fractions allowed, zero and negative rejected. The activation ceiling is PR 5.

Parsing

  • version (research/01 §1.1): only 1 is accepted. Missing, 0, a string, a fraction, and any other integer are errors with a message naming the problem, so a future schema is not read with today's meanings. Also recorded in questions.md.
  • Unknown event names are a warning and their definitions are skipped; unknown fields inside a definition are a warning each; prompt and model on a command hook are accepted silently because Cursor documents them.
  • Prompt hooks are skipped one definition at a time, with a warning, on every event.
  • A matcher that does not compile skips only its definition, with a warning naming the source and index (§6.4). Every other invalid field (missing command, bad timeout, bad loop_limit, unknown type, a wrongly typed field) is still an error for the file, as in PR 2: those are mistakes the author meant to run, while an unsupported handler and a bad regex are things the architecture says to report and skip.
  • A matcher on an event with no documented target (sessionStart, sessionEnd, preCompact, workspaceOpen; research/01 §1.4) is dropped with a warning and the definition matches every occurrence.
  • A relative command resolves against the registry's working directory; PR 5 owns resolution.

Matching (§6.4; research/01 §1.4; coverage §4.2)

  • Regex flavor and anchoring: Rust regex, unanchored search, case-sensitive. Cursor names none of these.

  • A source is read once. parse compiles each pattern while it validates the definition and keeps the definitions, the document, and the compiled patterns per source (sources.rs). bindings is handed the document back by the trait; when it is the document parse kept for that source, the kept definitions are reused and nothing is read or compiled again. A document the adapter did not parse (another adapter instance did, or the registry changed it) is read again and replaces the entry, so a reload does not accumulate patterns. Matching looks patterns up by text and compiles only for a binding that was not declared from a parse (a test building one by hand). The adapter holds this state because the trait gives bindings only the document; without it every pattern compiled twice per activation.

  • Matched value per event:

    EventsMatcher is tested against
    preToolUse, postToolUse, postToolUseFailurethe canonical tool name (Shell, Read, Write, Delete, Grep, Task, MCP:<tool>, and Warp's own names such as Glob, SearchCodebase, InitProject)
    beforeShellExecution, afterShellExecutionthe model's full command
    beforeMCPExecution, afterMCPExecutionthe MCP tool's own name without the MCP: prefix (Cursor lists no target for these; the tool name is what their payload calls tool_name)
    subagentStart, subagentStopthe subagent type, which no Phase 1 payload carries
    beforeReadFileRead
    afterFileEditWrite
    beforeTabFileReadTabRead
    afterTabFileEditTabWrite
    beforeSubmitPromptUserPromptSubmit
    stopStop
    afterAgentResponseAgentResponse
    afterAgentThoughtAgentThought
    sessionStart, sessionEnd, preCompact, workspaceOpeneverything
  • afterShellExecution matches a tool.result only when a RequestCommandOutput action finished (Completed or ShellRecovered). A long-running snapshot, a denied or cancelled command, and ReadShellCommandOutput, WriteToLongRunningShellCommand, and TransferShellCommandControlToUser results do not match: the completion funnel reports a long-running command's exit once more on the command's own action, so the command is counted once (facets.rs; §9.2).

  • beforeMCPExecution and afterMCPExecution fire for CallMCPTool only. ReadMCPResource is reachable through preToolUse as MCPResource:<name>; coverage §3.10 suggested also firing the MCP events for it, which would need a made-up tool_name.

  • A binding never matches an envelope of another boundary, so a beforeShellExecution binding handed a tool.result returns false even though both are shell facets.

Encoding (§4.2, §4.5; research/01 §2.1, §3)

  • Common fields on every event: conversation_id, generation_id, model and model_id (when known), model_params: [] (Warp has no structured parameters; coverage §3.0 suggests an empty array), hook_event_name, cursor_version and warp_version (both Warp's version; §4.5), workspace_roots (the detected roots, or the working directory when none), user_email (null when unknown), transcript_path: null.
  • tool_use_id (the action id) is added to every tool event, as Cursor's release notes document.
  • preToolUse.tool_input for a shell command carries command, cwd, and the model's is_read_only, is_risky, wait_until_completion, uses_pager, and rationale when present (coverage §3.8 lists these as available beyond Cursor's schema). agent_message is not available at the interception point and is omitted.
  • afterShellExecution carries command, output, duration (the payload's measured duration, or the gap between the result's timestamps), and exit_code when the shell reported one. Cursor documents no exit code field; it is added because the output alone cannot say whether the command succeeded. sandbox is omitted because Warp does not know it here.
  • MCP events send tool_input as a JSON string, as Cursor documents for them, unlike preToolUse's object. mcp_server_name, url, and command are not known in this crate and are omitted; mcp_server_id is sent under its own name. result_json is the MCP result as the rmcp crate serializes it, or {"error": "<message>"} for a failed call.
  • A cancelled MCP call never reaches afterMCPExecution: PR 1's completion funnel sends a cancelled result to tool.failure, so the MCP view the encoder reads carries either the returned result or the error text and has no cancelled case.
  • A payload value that cannot be serialized to JSON is written as null and logged with the field name, so a hook still receives the rest of the payload.
  • postToolUse.tool_output is a JSON string of a kind-tagged object per result variant (snake_case of the variant). File contents and command output are carried in full (§12: payloads are not redacted); binary file contents and screenshot bytes are summarized by size; the RunAgents harness_auth_secret_name is not part of tool_input.
  • postToolUseFailure: error_message is the result's Display text; failure_type is permission_denied for a denylisted or hook-denied command and a denied run_agents, error otherwise (Cursor's timeout has no Warp source); is_interrupt is true for a cancellation; the cancellation reason name is added as cancellation_reason.
  • beforeSubmitPrompt.attachments: each entry of the envelope's attachment summary becomes { "type": "file", "file_path": <entry> }. The summary is empty until PR 7 fills it; rule attachments will need a typed field on the envelope.
  • sessionStart: session_id (the conversation id), is_background_agent, composer_mode: "agent" (coverage §3.1). stop: status (completed, aborted for a cancellation, error) and loop_count. afterAgentThought: text and duration_ms when measured.
  • Events Warp does not fire encode only the common fields; an event handed a payload it is not about adds nothing.

Decoding (§6.4; research/01 §2.2, §2.4, §6.3, §7; research/05 §4)

  • Exit 2 denies on the permission hooks and halts the prompt on beforeSubmitPrompt, reading any messages printed; on every other event it is a plain failure under the failure policy (which, for those events, means nothing happens).
  • permission is required on every permission hook: a JSON object without it, or with a value other than allow, deny, or ask, is a schema mismatch and denies even when the definition fails open. Wrongly typed documented fields (user_message: 5) are schema mismatches too; extra undocumented keys are not.
  • ask (§4.5; research/01 §7 item 1; research/05 §4) means something different on each permission hook; the per-event table above has the rule for each, and the event table in events.rs carries it as AskRule. Also recorded in questions.md.
  • Messages on an ask (§6.5, §9.3): an ask becomes a denial on a surface with no one to ask, so its messages are kept the way a denial's are: agent_message (or user_message when there is no agent message) is a reason the model can read, and a user_message that is not already the reason is a notice. The reason is in HookEffects.reasons from the start, so PR 6's degrade step does not need an adapter change.
  • agent_message on an allow: Cursor documents it as the message sent to the agent when denied, so it is logged and dropped rather than carried as model context. user_message on an allow follows the per-event table above (research/01 §2.4); the event table carries the deny-only flag.
  • updated_input (§4.4, §6.2): on every permission hook it becomes replacement_input, which PR 1's permission policy denies in Phase 1. Cursor documents the field only on preToolUse; on beforeShellExecution and the others a rewrite request is still a control effect that cannot be dropped silently, so the action is denied rather than run with its original input, and the request is also reported as an unsupported effect naming the event. Claude's nested updatedInput counts only on preToolUse, where Cursor reads it.
  • additional_context becomes model context on sessionStart, postToolUse, and postToolUseFailure, which validate_capabilities already reports as unsupported in Phase 1; elsewhere it is ignored with a log line.
  • sessionStart.env must be an object of strings; anything else is a schema mismatch, which for sessionStart means no environment and no context. continue and user_message are accepted and ignored, as Cursor documents.
  • beforeSubmitPrompt: continue is required; false halts with user_message as the reason (or a Warp-written one); true makes user_message a notice.
  • stop and subagentStop: a non-empty followup_message becomes a continuation carrying the binding's loop_limit; an empty one is no follow-up.
  • Claude-shaped output (research/01 §6.3): accepted exactly where Cursor says it translates it, preToolUse, stop, and subagentStop, as the per-event table above shows. On every other event hookSpecificOutput is an undocumented key, so a beforeShellExecution hook that answers only in Claude's shape has no decision and denies. Also recorded in questions.md.
  • Fields an event does not document (§4.3, §4.4): one that names a control effect Cursor defines on some other event (permission, hookSpecificOutput, updated_input, updated_mcp_tool_output, continue, followup_message, decision) is never applied and is reported as an UnsupportedEffect on the binding, which the dispatcher already turns into a diagnostic for the source. An observational field the event does not document (additional_context, env, a message) is only logged. A field Cursor documents as accepted and ignored (continue and user_message on sessionStart) is neither.
  • Decision words are compared as written: permission, decision, and permissionDecision must equal allow, deny, ask, or block exactly, with no trimming or control-character cleaning, so a value that only cleans up to a known word is a schema mismatch. Messages are still cleaned.
  • Warp-written reasons name the hook by its configured command after the same cleaning hook messages get, so a command string that carries terminal escapes cannot put them into a reason; a command with no printable text is named by its binding id.
  • Strict hook policy (§6.4): for events that can block (the permission hooks and beforeSubmitPrompt), every evaluation failure, including timeout, crash, empty output, and malformed output, blocks, and preToolUse ask is honored. It never weakens subagentStart's ask-means-deny. For observers it changes nothing. The dispatcher passes the default policy until PR 6 wires the setting. Also recorded in questions.md.
  • A cancelled hook blocks where it could have blocked (deny or halt) regardless of failClosed, as PR 2 decided; on observers it does nothing.
  • Reason and notice text is trimmed and stripped of control characters other than newline and tab. The Warp-written reason strings for the shell hook are the ones PR 2's slice tests pin.

Amendments

  • DecodePolicy (§6.4): whether a schema-accepted ask is honored, and whether a failed hook blocks, depends on the vendor event, which only the adapter knows, so the strict setting has to reach decode_output. It is a per-call input rather than a field on the binding because it is a Warp setting, not a property of the source.
  • DeclaredBinding.loop_limit: the contract asks for the per-definition cap to be stored on the binding so PR 7 can enforce it without reading vendor configuration. Putting it on the binding also lets the decoder pass it through with the continuation.
  • HookEffects.unsupported (§4.4): the architecture's report channel for a control effect a binding cannot produce is the messenger, and the dispatcher already forwards MergedEffects.unsupported to it. Only the adapter-to-dispatcher hop was missing; reduce_effects now carries each binding's entries into the merged list.

Departures from architecture.md

  • Amendment to PR 3 from PR 6 (65a41d610): report_undocumented_fields in crates/ai/src/hooks/adapters/cursor/decode.rs reads a control field's value before reporting it on an event that does not define it. A value that asks for nothing (continue: true, permission: "allow", a decision other than "block", an empty followup_message) is logged and not reported; a rewrite, a result replacement, and Claude's nested object are still reported. PR 6's dispatcher denies an action before an operation for any reported control field, so without this a benign continue: true copied from Claude-shaped output denied a shell command. Test: undocumented_control_fields_whose_values_ask_for_nothing_are_only_logged.
  • Amendment to PR 3 from PR 7 (a779ec806): encode/mod.rs, encode/mod_test.rs, and matcher_tests.rs carry the provenance field PR 7 added to HookPayload::PromptSubmit (PR 1 commit 35faca3a5). facets::finished_shell_command in crates/ai/src/hooks/adapters/cursor/facets.rs returns None for a ReadShellCommandOutput result, so afterShellExecution no longer matches a poll of a running command: PR 7's completion funnel reports a long-running command's real exit as a second tool.result on the command's own RequestCommandOutput action, carrying the command text, output, exit code, and duration, so the event fires exactly once per agent-started command (including ones that finish under a write or a handoff) and not for a user-started command the agent only polls. A matcher test covers a ShellRead poll; the afterMcpExecution fixture comment is updated because PR 7 also routes an MCP error result to tool.result.
  • Amendment to PR 1 (79fbe1a2e, formatting 6fc1bab54): HookFormatAdapter::decode_output in crates/ai/src/hooks/adapters/mod.rs gained a policy: &DecodePolicy parameter, and DeclaredBinding gained loop_limit: Option<u32>. The dispatcher passes DecodePolicy::default(); RecordingAdapter records the policies it is given. PR 2's branch got the matching one-line signature and field change in its own adapters/cursor/ files (aa6c0d0a4, formatting 8640b2e15) so every branch in the stack builds.
  • Amendment to PR 1 (c41714aaa, from review round 1): HookEffects in crates/ai/src/hooks/model.rs gained unsupported: Vec<UnsupportedEffect>, and reduce_effects extends the merged list with each binding's entries, so an adapter can report a control effect its event cannot produce and the dispatcher's existing diagnostic path delivers it. RecordingAdapter fills the field with an empty list.
  • Follow-through for PR 4's amendment to PR 1 (32d7f2b21): HookProcessOutcome gained stdout_truncated (PR 1 commit 17c9e32b1), and decode_output in adapters/cursor/decode.rs treats an exit 0 with a cut-off stdout as malformed output, even when the kept prefix parses: a denial on the permission hooks and the failure policy elsewhere. The test helpers set the field, and decode_tests.rs covers every Phase 1 event.
  • §4.2 says common fields are absent rather than fabricated, and generation_id maps to the turn id. Until PR 7 allocates turn ids, every event in a conversation carries the conversation id under generation_id, and sessionStart always will. The value is a real identifier that cannot collide with a turn id, and it is supplied because Cursor lists generation_id in the common base of every event, so an absent key could crash scripts that index it; PR 2's slice test asserts the key is present. A script that keys once-per-generation state on it runs once per conversation until PR 7.
  • §4.1 maps afterShellExecution and afterMCPExecution to tool.result, and PR 1's completion funnel sends any result where is_failed() || is_cancelled() to tool.failure. In this PR those two events therefore do not fire for: a shell command that finished with a non-zero exit code (including exit 130), a ShellRecovered result, and an MCP tool that returned CallMCPToolResult::Error (the afterMCPExecution/stdin.json fixture is built from that shape on a tool.result payload, which production cannot produce today). An audit hook on either event sees successes and no failures until PR 7. The matcher and encoder read tool.failure payloads the same way as tool.result ones, so PR 7, which owns the funnel and the result boundaries, can reroute without adapter changes; the recommendation is to classify "the tool ran and returned" as tool.result regardless of the result's success (shell Completed at any exit code, ShellRecovered, CallMCPToolResult::Error). Recorded in questions.md for PR 7's kickoff.
  • §4.2 says the MCP facet adds stable server identity and transport metadata. The envelope carries only the server id, so mcp_server_name, url, and command are absent from the MCP payloads. Filling them needs an envelope field that app resolves from the MCP manager (PR 7 or a later amendment).
  • Files outside this PR's ownership that changed: none on this branch. The PR 1 amendment and the PR 2 follow-through are on their own branches.

Linked Issue

No linked issue. Part of the agent hooks Phase 1 stack; design is in specs/agent-hooks/architecture.md.

  • The linked issue is labeled ready-to-spec or ready-to-implement.
  • Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes).

Testing

All commands run with CARGO_TARGET_DIR=/Users/david/src/warp/target. The warp command also set WARP_LOCAL_SKIP_METAL=1, which this machine needs because it has no Metal toolchain.

  • cargo check -p ai — passed
  • cargo nextest run -p ai -E 'test(cursor)' --no-fail-fast — passed (95 tests)
  • cargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)' --no-fail-fast — 158 of 160 on this branch; the two others are PR 4's unix process-tree tests, which pass when run alone (the suite includes PR 2's slice tests, which pin the shell reason strings)
  • cargo clippy -p ai --features local_fs,test-util --all-targets --tests -- -D warnings — passed
  • cargo clippy -p ai --all-targets --tests -- -D warnings — passed
  • On the top of the restacked stack (david/hooks/07-remaining-boundaries): cargo check -p ai — passed; cargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)' — 265 of 270, the rest being PR 4's process-tree tests (pass alone) and PR 5's real-watcher test (fails alone on this machine before and after this round's commit); WARP_LOCAL_SKIP_METAL=1 cargo nextest run -p warp -E 'test(hooks)' — passed (116 tests)
  • ./script/format — ran last on this branch; a no-op on the top branch
  • On PR 1's branch after each amendment: cargo check -p ai --features local_fs,test-util and cargo nextest run -p ai --features local_fs,test-util -E 'test(hooks)' — passed (35, then 36 tests), plus clippy and the format check for the second. On PR 2's branch after the follow-through: the same two commands — passed (95 tests).

New tests, by module:

  • events: the 21 names in Cursor's order, each resolving back to its entry, with the table length checked against the enum's variant count and no event listed twice; the boundary mapping; the twelve Phase 1 events and the nine that never match; the six permission hooks as the only ones that deny on malformed output; empty-output rules; each event's control effect agreeing with its declared effects; the ask and deny-only user_message rules of each permission hook; matcher targets; capabilities with and without failClosed; default timeouts; full_config.json declaring 18 bindings in table order with the right options and loop limits; array positions preserved.

  • config: a minimal file; full_config.json parsing every event and option with its eleven expected warnings; version rules; no hooks object; invalid JSON; unknown_event.json; unknown fields versus prompt fields; prompt_hook.json keeping positions; unknown type; missing command; wrongly typed fields; bad and fractional timeouts; invalid_matcher.json skipping one definition; match-all matchers; a matcher on an event without a target; loop_limit default, zero, in range, capped, null, invalid, and on the wrong event; non-array event values; never-fired events warned per definition; table order regardless of file order.

  • matcher: empty, *, and missing matchers; unanchored case-sensitive regex; preToolUse against Shell, Read, Write, Grep, Task, MCP:read_file, Glob, SearchCodebase, and InitProject; post-tool events on the tool name; shell events on the command text and only for finished commands; MCP events on the bare tool name; literal events; events without a target; boundary mismatches; afterFileEdit never matching on its boundary; Grep as a tool name rather than a command.

  • sources: a kept parse answered only for the document it came from, with the regex compiled at parse being the one matching uses; a reparse replacing the source's entry; a pattern that was not kept compiling on first use.

  • encode: every Phase 1 event against its stdin.json fixture (JSON carried as text compared as parsed JSON); generation_id from the turn id and its fallback to the conversation id; absent and null common fields; both version fields; duration from timestamps and the missing exit code of a recovered shell; the MCP result string; a cancelled tool as an interrupt; tool_input for read, edit, MCP, and run_agents actions; tool_output with text and binary files; never-fired events encoding only common fields; a payload the event is not about.

  • decode: for every Phase 1 event, exit 2, five failure kinds under both policies, cancellation, empty output under both policies, non-JSON and schema mismatches under both policies; allow, deny, and their messages on each permission event, including the deny-only user_message on preToolUse; ask on each event it means something different on, with its messages kept as reasons; updated_input carried on every permission event and reported where undocumented; undocumented control fields reported and continue: false on stop under both policies; decision words compared as written; a cleaned command in Warp-written reasons; additional_context, env, updated_mcp_tool_output; continue false and true; followup_message with the binding's loop limit; the Claude shapes from claude_shape_output.json and their rejection elsewhere; strict mode on blocking and non-blocking events; control characters; every stdout.json fixture decoding to its expected effects.

  • mod: the registry entry; parse warnings as diagnostics on the source; bindings through the trait with activation warnings only where context or rewrites are documented; invalid_matcher.json through the trait; a shell hook end to end through all five trait methods; a binding under an unknown event name; an undocumented permission from afterShellExecution reaching the messenger as a diagnostic through the real dispatcher; a document that stopped parsing; bindings reusing what parse kept on the same adapter while a fresh adapter reads the document again and declares the same bindings.

  • I have manually tested my changes locally with ./script/run

Manual testing is deferred to the PR 9 instructions; the flag is off and no source activates in production yet.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-NONE

Activity

10
D
9b02da2
vorporeal9d ago

WARNING

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite. Learn more

  • #16605
  • #16602
  • #16593
  • #16590
  • #16588
  • #16583
  • #16582 👈 (View in Graphite)
  • #16579
  • #16577
  • #16558
  • master

This stack of pull requests is managed by Graphite. Learn more about stacking.

This pull request is a draft

Merging is unavailable until it is marked ready for review.