Retornatus

Agent hooks

Four opt-in hooks sit in the agent loop. A session-start hook injects the active Change so the agent begins with the finish line. A file-edit hook warns when an edit leaves that Change's declared scope. A Stop hook asks the agent to keep going when the Change is not SATISFIED. A subagent-stop hook asks the subagent to keep going with that same reminder. Git hooks (hooks install) still run at commit time.

All three can be skipped, all three fail open, and a host can cap how many times Stop continues the turn. CI remains the source of truth. The pull-request check (verify, gate suppressions, gate scope) is the result that counts. The file-edit hook is an early warning. It does not replace gate scope. See Cloud agents and GitHub Action.

SubagentStart and MCP are not installed. The subagent-stop hook is. The file-edit hook is the only tool hook, and only on the edit events documented below.

This repository does not write the hooks into its own .claude/, .cursor/hooks.json, or .codex/. Run the command in the project you want to guard.

Install

The CLI must be on PATH (uv tool install retornatus). Plain integrate does not change hook files.

retornatus integrate --hooks
retornatus integrate --hooks --host cursor
retornatus integrate --remove-hooks
retornatus doctor

--host is repeatable. Omit it to update Claude, Cursor, and Codex. --remove-hooks deletes only the Retornatus Stop, subagent-stop, session-start, and file-edit commands. Other hooks and keys stay. A second --hooks updates those entries in place and does not add a duplicate.

doctor prints agent hooks: for each host. absent means the file is missing, unreadable as JSON is unreadable, and a file with no Retornatus command is absent. When a Retornatus command is present the line names each hook, for example stop=installed session-start=installed file-edit=installed subagent-stop=installed or stop=installed session-start=absent file-edit=absent subagent-stop=absent. Absent is normal. The hooks are opt-in, and a missing hook does not fail doctor. For an initialized project, doctor also prints hooks allow_questions:, hooks subagent_stop: (true, false, or invalid), and hooks scope_mode: as warn, block, off, or invalid.

What the hook runs

Each host runs:

retornatus hook stop --host claude
retornatus hook stop --host cursor
retornatus hook stop --host codex

The command reads the host JSON object from stdin. Extra fields are ignored. A non-object is treated as an empty object. It finds .retornatus/ from the working directory (parents included) and loads every Change whose Contract is active, the same list as the git pre-commit scope hook.

It then runs the same in-process evaluation as verify --json. There is no network call and no subprocess back into the CLI.

SituationResult
No .retornatus/, or no active ChangeExit 0, no stdout. The turn ends.
Every active Change is SATISFIEDExit 0. The turn ends. Stdout stays empty unless a skill candidate is pending (see below).
Any active Change is not SATISFIED, and the turn is not a question or an interrupted Cursor runExit 0 and a host JSON object that continues the turn. The text names the unproven claim ids and a retornatus evidence run … command. When [assurance] required_checks is set, that command uses the first matching argv.
The last assistant message is a question to the user, and [hooks] allow_questions is not falseExit 0, no stdout. The turn ends so the person can answer.
Invalid JSON, unknown host, or any internal errorExit 0, no block payload. One fail-open line goes to stderr.

Claude and Codex receive:

{"decision": "block", "reason": "C-0001 is NOT_SATISFIED. Unproven claims: C-0001/claim-done-1 (INCONCLUSIVE). Next: retornatus evidence run …"}

Cursor receives:

{"followup_message": "C-0001 is NOT_SATISFIED. Unproven claims: C-0001/claim-done-1 (INCONCLUSIVE). Next: retornatus evidence run …"}

decision is omitted when the stop is allowed. Cursor's followup_message is omitted in that case too, unless a skill candidate is pending. The block text is the same when the hook does continue the turn for an unproven claim. A pending candidate is not a reason to block.

Skill candidates

When Assurance already allows the stop, the hook may append one line:

1 skill candidate pending: run `retornatus skill candidates`

Cursor returns that line as followup_message and nothing else. Claude Code and Codex return {"additionalContext": "<the line>"} and do not set decision, so the turn is allowed. The line is sent once per session (session_id, else conversation_id, else composer_id). A later stop in that session is empty again. loop_count of 1 or more skips the line. No pending candidate leaves stdout empty, as before.

The same stop also runs the repetition detector. It queues at most one new candidate for the session. [adaptation.skill_candidates] enabled = false turns the scan and the line off. The detector does not call a model. See Skills.

Subagent stop does not add this line.

Questions to the user

Stopping to ask the person a question is allowed. A previous version blocked that turn once, the same way it blocks an unfinished Change. The check is deterministic and does not call a model.

[hooks] allow_questions in .retornatus/config.toml defaults to true. Only a boolean false turns it off. A missing key, a missing file, or any other value keeps the default. doctor prints hooks allow_questions: true or false for an initialized project. This table is Retornatus config. It is not the Codex [hooks] table, which lives in Codex's own config.toml.

[hooks]
allow_questions = true

The text comes from a documented payload field when the host has one. Otherwise the hook reads the tail of transcript_path. If neither is available, the hook behaves as it did before this exception: an unsatisfied Change still blocks.

HostAssistant text on StopTranscript path
Claude Codelast_assistant_message (text of the final response). Prefer this. The transcript can lag the current turn.Common field transcript_path (a .jsonl path)
Codexlast_assistant_message (string or null)Common field transcript_path (string or null). The line format is not a stable interface.
CursorNo assistant-text field on stop. afterAgentResponse.text is a different event and is not read.Common field transcript_path (string or null). The stop object itself is status and loop_count.

Checked against the host docs on 2026-09-30:

Every field is optional. Extra fields are ignored. Cursor does not use last_assistant_message even if a caller adds it, because that field is not in the Cursor stop schema.

The transcript read is the last 256 KiB. The partial first line after that cut is dropped. Lines are UTF-8 JSON objects. Malformed lines, non-objects, and lines that are not an assistant turn are ignored. An assistant line is one whose type or role is assistant (also accepted on message). Text is an optional string at text or content, or message.content when that is a string or a list of text blocks (type text and string text). Tool, result, and thinking blocks are skipped. A missing file, a non-file, or a read error leaves the text unavailable.

Heuristic, applied to that text:

  1. Normalize newlines and strip trailing whitespace.
  2. Drop a trailing unclosed fence, then drop trailing closed ` blocks.
  3. Take the last paragraph that still has visible prose. Paragraphs are separated by a blank line.
  4. Remove fenced code, inline code spans, and URL tokens (http://, https://, www.) from that paragraph.
  5. It is a question when the remainder ends with ? or full-width ? (trailing quotes and brackets do not count), or when it contains one of these phrases at the start or after a sentence boundary: should i, shall i, do you want, would you like, want me to, quer que eu, posso seguir, posso continuar, devo seguir, devo continuar, você quer, voce quer, prefere que eu.

A ? only inside a code fence, an inline span, or a URL does not allow the stop. A question in an earlier paragraph does not count when the final paragraph is a statement. stop_hook_active and Cursor status aborted or error are decided before this heuristic. They still allow the stop when allow_questions is false.

Session start

Each host also runs:

retornatus hook session-start --host claude
retornatus hook session-start --host cursor
retornatus hook session-start --host codex

The command reads the host JSON object from stdin and ignores extra fields. A non-object is treated as an empty object. It finds .retornatus/ from the working directory (parents included) and loads every Change whose Contract is active. Evaluation is the same in-process check as verify --json. There is no subprocess back into the CLI.

The injected text names, for each active Change:

A satisfied Change still appears, with unproven: none, so the session starts on the finish line. When a skill candidate is pending, the text ends with the line 1 skill candidate pending: run retornatus skill candidates. That line is also the whole message when no Change is active and a candidate is waiting. It does not block the session. The text is capped at 2048 UTF-8 bytes. A longer note ends with …(truncated).

SituationResult
No .retornatus/Exit 0, no stdout
No active Change, and no pending skill candidateExit 0, no stdout
A pending skill candidateExit 0 and the host JSON. The context includes the line 1 skill candidate pending: run retornatus skill candidates
[hooks] session_context = falseExit 0, no stdout. The default is true, including when the key is absent
One or more active ChangesExit 0 and the host JSON below
Invalid JSON, unknown host, a non-boolean session_context, or any internal errorExit 0, no context. One fail-open line goes to stderr

Claude and Codex receive:

{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "Retornatus active Change context:\nC-0001 Health command — INCONCLUSIVE\ngoal: The health command exits 0\nscope: src/retornatus/\nunproven: C-0001/claim-done-1 (INCONCLUSIVE). Next: retornatus evidence run …"}}

Cursor receives:

{"additional_context": "Retornatus active Change context:\nC-0001 Health command — INCONCLUSIVE\ngoal: The health command exits 0\nscope: src/retornatus/\nunproven: C-0001/claim-done-1 (INCONCLUSIVE). Next: retornatus evidence run …"}

Checked against the host docs on 2026-09-30. All three hosts document this event, so all three are installed:

[hooks] session_context is Retornatus project config in .retornatus/config.toml, the same table as allow_questions and scope_mode, not the host's [hooks] table. Codex can also read inline [hooks] from its own config.toml. integrate --hooks still writes .codex/hooks.json only.

[hooks]
session_context = false

File edit

Each host also runs:

retornatus hook file-edit --host claude
retornatus hook file-edit --host cursor
retornatus hook file-edit --host codex

The command reads the host JSON object from stdin and ignores extra fields. A non-object is treated as an empty object. It finds .retornatus/ from the working directory (parents included) and loads every Change whose Contract is active. Each edited path is resolved against the repo root and compared with path_in_scope, the same match gate scope uses for Task resources and .retornatus/**. There is no subprocess and no git call.

A path inside the declared scope, a project with no active Change, and anything under .retornatus/ print nothing. The edit proceeds. A path outside every matching rule prints a short warning: the path, each active Change that does not cover it, that Change's declared scope, and the next step (revert the edit, or add the path to the Change's Task resources). One file must sit inside every active Change, the same way the pre-commit scope hook checks the diff against each active Change.

SituationResult
No .retornatus/, or no active ChangeExit 0, no stdout
Path in scope for every active Change, or under .retornatus/Exit 0, no stdout
[hooks] scope_mode = "off"Exit 0, no stdout. The default is warn, including when the key is absent
Out of scope, mode warnExit 0 and the host warning below. The edit is not blocked
Out of scope, mode block, on an event that documents a denyExit 0 and a deny payload. The warning is the deny reason
Out of scope, mode block, on an event that cannot denyExit 0 and the warn payload for that event
Invalid JSON, unknown host, a bad scope_mode, or any internal errorExit 0, no decision. One fail-open line goes to stderr

[hooks] scope_mode accepts warn, block, or off. Any other value fails open.

[hooks]
scope_mode = "warn"

Checked against the host docs on 2026-10-01. All three hosts have a documented way to put a message back in front of the agent, so all three are installed. Events that cannot carry that message are skipped.

Claude and Codex, warn mode:

{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": "src/other.py is outside C-0001 scope (src/retornatus/). Revert the edit, or update the Change scope (Task resources) to include this path."}}

Claude and Codex, block mode:

{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "src/other.py is outside C-0001 scope (src/retornatus/). Revert the edit, or update the Change scope (Task resources) to include this path."}}

Cursor warn mode (postToolUse):

{"additional_context": "src/other.py is outside C-0001 scope (src/retornatus/). Revert the edit, or update the Change scope (Task resources) to include this path."}

Cursor block mode (preToolUse):

{"permission": "deny", "agent_message": "src/other.py is outside C-0001 scope (src/retornatus/). Revert the edit, or update the Change scope (Task resources) to include this path."}

Files

Fresh install, with no other keys in the file:

.claude/settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook stop --host claude",
            "timeout": 30
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook subagent-stop --host claude",
            "timeout": 30
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook session-start --host claude",
            "timeout": 30
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook file-edit --host claude",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

.cursor/hooks.json

{
  "version": 1,
  "hooks": {
    "stop": [
      {
        "command": "retornatus hook stop --host cursor",
        "loop_limit": 1
      }
    ],
    "subagentStop": [
      {
        "command": "retornatus hook subagent-stop --host cursor",
        "loop_limit": 1
      }
    ],
    "sessionStart": [
      {
        "command": "retornatus hook session-start --host cursor",
        "timeout": 30
      }
    ],
    "preToolUse": [
      {
        "command": "retornatus hook file-edit --host cursor",
        "matcher": "Write",
        "timeout": 30
      }
    ],
    "postToolUse": [
      {
        "command": "retornatus hook file-edit --host cursor",
        "matcher": "Write",
        "timeout": 30
      }
    ]
  }
}

.codex/hooks.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook stop --host codex",
            "timeout": 30
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook subagent-stop --host codex",
            "timeout": 30
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook session-start --host codex",
            "timeout": 30
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "apply_patch|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "retornatus hook file-edit --host codex",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Existing version, permissions, matchers, and other commands stay. The Retornatus command is the identity of the entry, so a later install can replace the timeout or loop_limit without adding a second hook.

Subagent stop

Each host also runs:

retornatus hook subagent-stop --host claude
retornatus hook subagent-stop --host cursor
retornatus hook subagent-stop --host codex

The command is the Stop decision again. It reads the host JSON object from stdin, ignores extra fields, and evaluates every active Change in-process. There is no second copy of the Assurance check.

SituationResult
[hooks] subagent_stop is boolean falseExit 0, no stdout. The subagent finishes.
No .retornatus/, no active Change, or every active Change is SATISFIEDExit 0, no stdout.
Any active Change is not SATISFIED (missing evidence, stale evidence, or any other unproven claim), and the subagent is not asking a question or ending on a Cursor interruptExit 0 and the same host JSON the Stop hook writes.
The subagent's last message is a question, and [hooks] allow_questions is not falseExit 0, no stdout.
Invalid JSON, an unknown host, a non-boolean subagent_stop, or any internal errorExit 0, no reminder. One retornatus hook subagent-stop: fail-open line goes to stderr.

[hooks] subagent_stop defaults to true. A missing key keeps that default. Only boolean false turns the reminder off. doctor prints hooks subagent_stop: true, false, or invalid.

Claude Code and Codex answer with decision: "block" and reason on stdout and exit 0. Exit 2 is the other way those hosts continue a subagent; this command uses the JSON decision, the same path as Stop. Cursor answers with followup_message and no decision. The host applies that field only when status is completed.

Question text uses the field the host documents for this event. Claude Code and Codex prefer last_assistant_message. A string wins. null or a missing field falls through to agent_transcript_path (the subagent transcript), then the parent transcript_path. Cursor subagentStop has no last_assistant_message. It reads agent_transcript_path, then transcript_path, then summary. The question heuristic is the one in Questions to the user.

Checked against the host docs on 2026-10-01:

modified_files is extra input. The reminder does not filter claims by those paths. A Change that is not SATISFIED is an open proof obligation, including evidence that is missing or stale for the claims the work touched.

Loop guard

Claude and Codex set stop_hook_active to true when this turn is already a continuation from a Stop hook. The command allows the stop in that case, so it does not block twice in a row. Claude also stops continuing after eight blocks in a row (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). Codex treats continue: false from any matching Stop hook as stronger than a block from another hook.

Cursor has no stop_hook_active field. The generated stop and subagentStop entries each set loop_limit to 1 (the host default is 5). That is one automatic follow-up. followup_message is only applied when status is completed. An aborted or error status allows the stop. failClosed is left at its default, false, so a crash or a timeout does not trap the agent. Claude Code and Codex set stop_hook_active on SubagentStop as well as on Stop. The subagent command allows the finish when that field is true.

Fail-open

A policy hook that crashes should not freeze the session. Invalid stdin, an unknown --host, a raised exception, and a missing project all exit 0 with no block JSON. The diagnostic is a single stderr line that starts with retornatus hook stop: fail-open, retornatus hook subagent-stop: fail-open, retornatus hook session-start: fail-open, or retornatus hook file-edit: fail-open. The command timeout in the Claude and Codex files is 30 seconds (the host default is 600). Cursor's session-start and file-edit entries use the same 30 second timeout. Cursor's subagentStop entry sets loop_limit instead of a timeout, the same as stop. failClosed stays at its default, false.

Codex trust

Codex does not run a project hook until you review and trust that exact definition. Trust is stored against the hook's hash, so the next edit is skipped until you trust it again in /hooks. Project hooks also require the .codex/ layer to be trusted. Untrusted projects still load user and system hooks from their own layers. This page does not enable --dangerously-bypass-hook-trust.

Codex can also read inline [hooks] from config.toml. If one layer contains both hooks.json and [hooks], Codex loads both and warns. integrate --hooks writes .codex/hooks.json only.

Host notes that differ from earlier write-ups

Checked against the host docs on 2026-09-30:

Related

← Docs hub Product site →