@lannguyensi/understanding-gate
v0.7.0
Published
Pre-execution gate that asks AI agents to produce an Understanding Report before acting
Readme
understanding-gate
Pre-execution gate for AI agent harnesses. Before an agent edits files, runs destructive commands, or opens PRs, this tool asks it to emit a structured Understanding Report so a human can confirm, correct, or "grill me" before execution begins.
Status: Phase 2 (enforcement) shipped. Phases -1, 0, 0.5, 1, 2 are live: prompt-hook gate, structured report parsing + persistence, and tool-blocking until the report is approved. Phase 3 (agent-tasks lifecycle integration) is next. See ROADMAP.md.
What it does
The gate sits in front of your agent harness as two layers, intentionally separated so each does one job well:
Layer 1, the cooperative gate (Phase 0). A UserPromptSubmit hook injects an instruction into every task-like prompt asking the agent to first produce a report covering: current understanding, intended outcome, derived todos, acceptance criteria, assumptions, open questions, out-of-scope, risks, verification plan. A cooperative agent reads this and pauses for human confirmation. This is where most of the value comes from in practice: the agent slows down on its own and surfaces its interpretation before doing anything irreversible.
Layer 2, the enforced backstop (Phase 2). A PreToolUse hook blocks destructive tools (Write, Edit, MultiEdit, NotebookEdit, Bash on Claude Code; write, edit, bash on opencode) until the latest persisted Understanding Report has approvalStatus: "approved". Read-only tools (Read, Grep, Glob, LS, …) stay open at all times. Every block, approve, revoke, and force-bypass lands in .understanding-gate/audit.log. This is what fires when an agent ignores Layer 1, whether because of an aggressive prompt ("don't ask, just do"), a prompt-injection attack, or a less-cooperative model.
Two modes for the cooperative layer:
| Mode | When | Shape |
|---|---|---|
| fast_confirm (default) | low-risk, small tasks | 5-line summary, "please confirm" |
| grill_me | ambiguous, risky, broad | 9-section report, "please grill me" |
Escalation to grill_me: set UNDERSTANDING_GATE_MODE=grill_me, or include grill me / /grill in the prompt. Only grill_me (and the equivalent full template) produces a parseable report that gets persisted to disk; fast_confirm stays in-conversation.
When does the block actually fire?
Cooperative agent + cooperative prompt: rarely. The agent reads the Layer-1 template, emits its report, and waits for confirmation, so write tools never get attempted in the first place. The Layer-2 hook still runs on every tool call, but stays silent (read-only allowed; no audit entry).
Cooperative agent + aggressive prompt ("do it now, no waiting"): often. The agent may try to edit before the report cycle closes; Layer 2 then denies with a clear deny-reason and writes a block event to the audit log. The agent typically reads the deny-reason and falls back to producing the report.
Non-cooperative or prompt-injected agent: this is the case Layer 2 exists for. Every destructive tool call is denied as long as no approved report exists and no pause sentinel is active. The audit log is the trail you'll go back to in an incident review.
Two routes through Layer 2 without an approved report: force-bypass with UNDERSTANDING_GATE_FORCE=1 + a UNDERSTANDING_GATE_FORCE_REASON of at least 10 characters, or an active pause sentinel (see "Pause sentinel" below). Both are audit-logged: force-bypass as a force_bypass entry, a pause overriding what would otherwise have been a block or force-bypass as a paused_allow entry. Any force attempt with a missing/short reason is also audit-logged, as a block.
Why this exists
Agentic systems often fail at the transition from partially-understood task to real-world action. The agent infers too much too early, executes on wrong assumptions, and the result is off-target. A pre-execution gate makes the interpretation visible and reviewable before the first impactful action.
This is the front-of-pipeline counterpart to claim-gate (no claims without evidence) and review-claim-gate (no merge without checklist). Same family, earlier checkpoint.
Quickstart
Claude Code
npx @lannguyensi/understanding-gate init --target claude-codeWrites three hook entries into .claude/settings.json (project scope) or ~/.claude/settings.json (--scope user):
UserPromptSubmit, the Layer-1 prompt-template hook (Phase 0).Stop, which parses the agent's final message and persists the report (Phase 1).PreToolUse, the Layer-2 enforcement hook that blocks write tools until the latest report is approved (Phase 2).
All three are installed in one shot; you do not need to wire them up by hand. The UserPromptSubmit hook only fires on task-like prompts (keyword classifier), so non-task questions are unaffected. To remove the entries again, run understanding-gate uninstall --target claude-code (respects the same --scope).
opencode (v0.5)
npx @lannguyensi/understanding-gate init --target opencodeopencode has no per-prompt hook before model inference, so v0.5 installs three files:
.opencode/rules/understanding-gate.md, the static fast-confirm rule the agent always sees..opencode/command/grill.md, the explicit/grillcommand for the deeper challenge..opencode/plugins/understanding-gate-persist-report.ts, amessage.updatedplugin shim that parses the agent's report and writes it to.understanding-gate/reports/. Without this shim Phases 1 and 2 degrade silently (no persisted report means nothing to approve, so thePreToolUse/tool.execute.beforeblock has noapprovedmarker to look at).
The agent always sees the fast-confirm rule; the user invokes /grill for the deeper challenge. To remove the three files again, run understanding-gate uninstall --target opencode.
Testing the opencode transport_error path
transport_error breadcrumbs (.understanding-gate/parse-errors/, kind: "transport_error") are written when the plugin's message.updated handler fails to fetch the just-finished assistant message back from opencode's own client, not a network-configurable endpoint. The deterministic unit-level hooks for this already exist: tests/opencode-plugin-integration.test.ts (search for "transport_error") injects a rejecting / error-returning client.session.message and asserts the breadcrumb, in two cases. To force the same failure through a real, live opencode session instead of a unit test, wrap the ctx the init-generated plugin shim receives so client.session.message always throws; see the opencode npm dogfood doc, Scenario 2/Attempt C, for the exact recipe.
Non-interactive sessions (claude -p)
Phase 2 works under claude -p as long as the harness ships
last_assistant_message in the Stop-hook payload (recent Claude Code
releases do; the 0.2.1 release added preference for this field to
dodge a transcript-flush race). For older harnesses the gate falls back to
reading the transcript JSONL, which can race against the harness's
flush timing. If your .understanding-gate/reports/ stays empty
under a -p run while the agent's output clearly contains a
# Understanding Report, that race is the most likely cause; upgrade
the harness or run interactively as a workaround.
Approve / revoke the gate
# After the agent emits a report you accept, in another terminal:
understanding-gate approve # picks the latest report in cwd
understanding-gate approve --task-id <id>
understanding-gate approve --report-id <taskId|filename|path>
# Reverse it:
understanding-gate revoke
# Inspect:
understanding-gate status # current approval state in cwd
understanding-gate report list # all persisted reports
understanding-gate report show <id> # one report (taskId, filename, or path)The CLI flips the persisted report's approvalStatus field, which is the source of truth the PreToolUse hook reads. Each approve / revoke also drops a JSONL line in .understanding-gate/audit.log (block, approve, revoke, force_bypass).
Disable or force-bypass
# Kill switch (gate is off entirely):
UNDERSTANDING_GATE_DISABLE=1 claude
# Bypass enforcement once with a recorded reason (≥ 10 chars; logged):
UNDERSTANDING_GATE_FORCE=1 \
UNDERSTANDING_GATE_FORCE_REASON="incident-recovery for ticket 1234" \
claudeFORCE without a FORCE_REASON (or with one shorter than 10 chars) still blocks; the bypass is deliberately friction-bearing.
Pause sentinel (optional, read-only)
Set UNDERSTANDING_GATE_PAUSE_FILE to the path of a pause-sentinel JSON
file ({pausedAt, expiresAt, reason, pausedBy}) to make UserPromptSubmit,
PreToolUse, and opencode's tool.execute.before enforcement hook stay
silent (or, for PreToolUse / tool.execute.before, allow instead of
deny/block) while that sentinel is active: the UserPromptSubmit hook
skips the Understanding Report injection, and the PreToolUse and
tool.execute.before hooks skip their deny/block (all three use the
exact same reader, so a given sentinel file reads the same way on every
path). The Stop hook only persists reports and is unaffected by a
pause. A PreToolUse or tool.execute.before pause that overrides what
would otherwise have been a block or force-bypass is audit-logged as a
paused_allow entry; a pause that changes nothing (a read-only tool, an
already-approved report) stays silent, same as without a pause. This
package only ever reads the sentinel file; it never creates, writes, or
deletes it, and never manages expiry. Unset (the default) means no pause
check at all on any hook or plugin.
UNDERSTANDING_GATE_PAUSE_FILE must be set on both hook lines by any
consumer that wires understanding-gate-claude-pre-tool-use directly
(rather than through env plumbing that already exports it for the whole
process) -- each hook only sees the env var on its own command line, so a
sentinel wired to one hook and not the other silences only that one.
The sentinel is unsigned and operator-owned: this package trusts whatever is at the configured path and applies no signature or origin check, so treat write access to the sentinel file itself as equivalent to write access to pause enforcement everywhere it is checked.
opencode
The opencode tool.execute.before enforcement hook honors the same
pause sentinel, through the exact same isPaused reader as the Claude
Code hooks (no second parser), behaving like the Claude Code
PreToolUse path: an active sentinel overriding what would otherwise
have blocked a tool call is audit-logged as paused_allow (adapter:
"opencode", but with no accompanying stderr diagnostic the way
PreToolUse emits one -- the audit entry is the only observable
signal); a pause that changes nothing (a read-only tool, an
already-approved report) stays silent; a force-bypass under an active
pause keeps its own force_bypass audit kind rather than being folded
into paused_allow. This only covers opencode when
UNDERSTANDING_GATE_PAUSE_FILE is exported into the environment that
launches opencode (opencode has no per-hook-line settings.json
equivalent to wire it through, so it is read off the launching
process's env instead); unset there means no pause check on opencode at
all, even if the same variable is set for Claude Code's hooks.
Not implemented yet
Phases -1, 0, 0.5, 1, 2 are live. The following items are deliberately out of scope for the current release; some are scheduled for later phases, some are deferred indefinitely:
- Call an LLM for prompt classification. The task-like classifier is a deterministic keyword regex. Determinism plus zero per-prompt latency.
- Sync approval state to
agent-tasks. Phase 3 promotes the local marker to a first-class lifecycle state. - Auto-escalate to
grill_mebased on risk heuristics. Manual escalation only for now. - Time-based expiry of approvals. An approved report stays approved until you revoke.
Roadmap
See ROADMAP.md. Phases -1 / 0 / 0.5 / 1 / 2 shipped: prompt-hook, structured report, persistence, hypothesis bridge, tool-blocking enforcement. Phase 3 is agent-tasks lifecycle integration.
Design docs
The concept and architecture live in the project log:
lava-ice-logs/2026-04-29/pre-execution-understanding-gate.mdlava-ice-logs/2026-04-29/agent-harness-pre-execution-understanding-gate-architecture.md
Status
Experimental, pre-release. APIs may change between phases.
