hooks

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package hooks is behalf's Claude Code hook capture surface — the demo companion, scoped to one client (D4, Q44).

The MCP proxy is the canonical v1 surface. This is not a second one. It exists because three things happen inside Claude Code that an MCP proxy structurally cannot see:

  • The human's consent decision. `PermissionRequest` and `PermissionDenied` are the moment a person allowed or refused a tool call, captured as first-class `approval` / `denial` receipts anchored to the delegation token `jti` plus the intent digest (Q5, Q24).
  • The agent-to-subagent delegation edge. `SubagentStart` / `SubagentStop` hand us the human -> agent -> subagent hop as `delegation` receipts (Q1, Q5, D4).
  • Local tool calls. Bash, Edit and Read never cross an MCP boundary, so the proxy never sees them at all.

Scope, deliberately narrow

One client. There is no adapter layer, no plugin interface, and none is coming. The four-client hook story was refuted on ground truth (D4): Claude Code reads ~/.claude/settings.json, VS Code Copilot reads two files in two dialects, Cursor and Codex read machine-scoped files that need admin rights. Four files, three parsers, two needing root — and obot-sentry already owns that ground with MDM distribution behalf cannot match. Building an abstraction that implies otherwise would be the dishonest part.

Two weaknesses, recorded rather than hidden

**The observed user can delete the hook.** The hook configuration lives in a user-scoped settings file, so the person whose calls are being recorded can remove it — the same hole obot-sentry's own README concedes. This is out of scope for v1 integrity claims (Q74: a workstation user "can delete hooks or bypass the proxy"). It is not silently out of scope: it shows up as silence, and capture-coverage visibility is the only mitigation until managed policy settings arrive in act two. Nothing in this package pretends otherwise.

**A hook can observe pre-rewrite input.** `updatedInput` on `PreToolUse` is real, and the rewrite is applied at execution time — verified at runtime on Claude Code 2.1.247 (D4). So when another input-modifying hook is installed, what this surface records is the input the model proposed, not necessarily the input the tool ran. The proxy records the request actually forwarded. That asymmetry is one of the reasons the proxy is canonical, and it is why a receipt from this surface is a second observation of a crossing, never the authority on it (see dedup.go).

How a receipt gets built

The same path the proxy uses, through internal/capture: the emitter key and the cross-process monotonic counter from internal/identity (the flock matters — the hook binary and a behalf-proxy may be allocating counters against one state directory at the same instant), a client-minted ULID receipt_id (Q46), risk_class from a capture-time tool policy with its digest recorded (Q6), a step_key over the normalized argument schema and a durable per-run causal ordinal (Q85), run_id by the Q7 precedence with the Claude Code session id as the `hook-session` rung, payload blobs into the customer-held CAS with their field-digest manifests (Q37), DSSE-signed with the emitter key, durably spooled (Q48). `emitter.surface` is `claude-code-hook`.

The attempt contract, adapted

Q4 wants intent durable before the action and merged into one completion receipt. The proxy holds its pending calls in memory because it is one long-lived process. A hook is a fresh process per event, so the pending intent is durable on disk instead (pending.go): `PreToolUse` fsyncs it before the tool runs and emits no receipt; `PostToolUse` claims it and mints the single `tool_call` receipt; `PermissionDenied` claims it and mints a `denial` instead, because the tool will not run. Unclaimed intents become `orphan_intent` receipts — at `SessionEnd` for that session, or on an explicit `behalf-hook recover` sweep.

Pending intents are deliberately NOT written into the spool as `spool.Intent` records. If they were, any `behalf-log drain` against this spool would run the proxy's orphan recovery over them and mint `mcp-proxy`-surface receipts for hook-observed crossings. The spool this surface writes holds completions only, so draining it is safe with the shipped drain, unchanged.

Failure posture — the deliberate inversion

The proxy aborts when it cannot record: a recorder that cannot record must not forward the call. This surface does the opposite and exits 0 on every capture failure. A hook that fails closed breaks the user's editor session, and behalf is a recorder, not a runtime (Q47). The loss is visible as silence and as a gap in the per-emitter counter sequence, which is exactly what Q48 stamped the counter for. cmd/behalf-hook carries the same note at the point where it swallows the error.

Index

Constants

View Source
const (
	KindToolCall     = "tool_call"
	KindApproval     = "approval"
	KindDenial       = "denial"
	KindDelegation   = "delegation"
	KindOrphanIntent = "orphan_intent"
	KindAction       = "action"
)

Record kinds this surface mints (§3).

View Source
const (
	KindExtSubagentStart = "sh.behalf/claude-code/subagent_start"
	KindExtSubagentStop  = "sh.behalf/claude-code/subagent_stop"
	KindExtSessionEnd    = "sh.behalf/claude-code/session_end"
	KindExtStop          = "sh.behalf/claude-code/stop"
	// KindExtPostOnly marks a tool_call receipt built from PostToolUse alone,
	// with no PreToolUse intent behind it. The crossing is recorded; what is
	// NOT true of it is Q4's durable-intent-before-the-action property, and a
	// reader must be able to tell those apart.
	KindExtPostOnly = "sh.behalf/claude-code/post-only"
)

kind_ext values. The frozen `kind` enum is closed on purpose, so surface vocabulary that is not a new record type rides the verbatim, non-load-bearing `kind_ext` namespace (Q6).

View Source
const (
	EventPreToolUse        = "PreToolUse"
	EventPostToolUse       = "PostToolUse"
	EventPostToolUseFailed = "PostToolUseFailure"
	EventPermissionReq     = "PermissionRequest"
	EventPermissionDenied  = "PermissionDenied"
	EventSubagentStart     = "SubagentStart"
	EventSubagentStop      = "SubagentStop"
	EventSessionEnd        = "SessionEnd"
	EventStop              = "Stop"
)

The hook event names this surface handles. Claude Code sends one JSON object on stdin per event, with `hook_event_name` naming which one.

View Source
const ArgumentsPath = "$.arguments"

ArgumentsPath is the field-digest manifest path the proxy records the raw argument bytes under.

View Source
const CarriageRouteLocal = "local-file:sh.behalf/chain"

CarriageRouteLocal records how a delegation chain reached this surface.

The proxy's hops arrive beside the request in `params._meta` and say so. A hook has no request to ride beside: the chain is read from local configuration. That is a materially weaker carriage story — nothing tied this chain to this particular tool call — and the hop records it rather than borrowing the proxy's route string (Q15).

View Source
const CrossSurfaceRel = "attests"

CrossSurfaceRel is the `links[].rel` a hook receipt uses to flag itself as possibly-a-second-observation. The frozen enum offers six values; `attests` is the one that means "this record speaks to another record", which is what a second observation of one crossing does.

View Source
const DefaultPolicyJSON = `{"version":"behalf.sh/tool-policy/v1","default":"low","rules":[` +

	`{"pattern":"*refund*","class":"high"},` +
	`{"pattern":"*payment*","class":"high"},` +
	`{"pattern":"*charge*","class":"high"},` +
	`{"pattern":"*delete*","class":"high"},` +
	`{"pattern":"*write*","class":"medium"},` +
	`{"pattern":"*update*","class":"medium"},` +
	`{"pattern":"*create*","class":"medium"},` +
	`{"pattern":"*send*","class":"medium"},` +

	`{"pattern":"Bash","class":"high"},` +
	`{"pattern":"KillShell","class":"medium"},` +

	`{"pattern":"Write","class":"medium","target_arg":"file_path"},` +
	`{"pattern":"Edit","class":"medium","target_arg":"file_path"},` +
	`{"pattern":"NotebookEdit","class":"medium","target_arg":"notebook_path"},` +

	`{"pattern":"WebFetch","class":"medium","target_arg":"url"},` +
	`{"pattern":"WebSearch","class":"medium","target_arg":"query"},` +

	`{"pattern":"Task","class":"medium","target_arg":"subagent_type"},` +
	`{"pattern":"Agent","class":"medium","target_arg":"subagent_type"},` +

	`{"pattern":"Read","class":"low","target_arg":"file_path"},` +
	`{"pattern":"Glob","class":"low","target_arg":"pattern"},` +
	`{"pattern":"Grep","class":"low","target_arg":"pattern"},` +
	`{"pattern":"BashOutput","class":"low"}` +
	`]}`

DefaultPolicyJSON is the built-in policy for the hook surface. It is a real config, digested like any other, so a receipt written without a --policy file still says exactly what classified it.

View Source
const DefaultSettingsPath = "~/.claude/settings.json"

DefaultSettingsPath is Claude Code's user settings file.

View Source
const DefaultSpoolDirName = "hook-spool"

DefaultSpoolDirName is the hook capture spool under the state directory. It is deliberately separate from the proxy's `proxy-spool`: two surfaces, two spools, one drain command that works on either.

View Source
const InstallMarkerFlag = "--installed-by"

InstallMarkerFlag is written into every command line this helper installs, and is how --uninstall finds exactly our entries. The capture path accepts and ignores it.

View Source
const InstallMarkerValue = "sh.behalf/hook/v1"

InstallMarkerValue is the marker's stable value.

View Source
const ObservedClientVersion = "2.1.250"

ObservedClientVersion is the Claude Code build this surface's payload handling was checked against (ENG-33). Every golden under `testdata/` is either a capture from this build or was rewritten to match the payload schemas it carries; `testdata/PROVENANCE.md` says which, per file.

The payload shape is Claude Code's and it moves. This constant is here so a future reader can tell how stale the goldens are without guessing.

View Source
const OrdinalDirName = "hook-runs"

OrdinalDirName holds the per-run causal ordinal counters.

View Source
const OtelConventionsVersion = "1.29.0"

OtelConventionsVersion is the gen_ai.* semantic-conventions version in force at capture, stamped per record so old receipts can be re-normalised when the still-Development conventions move (Q8, Q49). It matches the proxy's: both surfaces are recording against the same conventions at the same moment, and two different values would be a lie about one of them.

View Source
const PendingDirName = "hook-pending"

PendingDirName is the pending-intent store under the state directory.

View Source
const PolicyDirName = "policy"

PolicyDirName holds the materialised built-in policy.

View Source
const Surface = "claude-code-hook"

Surface is the emitter.surface value for this capture surface (schema §5, Q44, D4). The enum already had it: the hooks companion was designed into the frozen schema, not bolted on.

Variables

View Source
var ErrUnhandledEvent = errors.New("hooks: event not handled by this surface")

ErrUnhandledEvent marks a well-formed payload for an event this surface does not receipt. It is not a capture failure: Claude Code may add events, and an unknown one must be ignored quietly rather than recorded as something it is not.

Events is every event this surface installs and handles, in the order the install helper writes them.

ToolMatcherEvents are the events Claude Code scopes with a `matcher`. The rest install without one.

Functions

func CrossSurfaceDigest

func CrossSurfaceDigest(r *receipt.Receipt) string

CrossSurfaceDigest returns the arguments digest a hook receipt flagged itself with, or "".

func ExpandPath

func ExpandPath(path string) (string, error)

ExpandPath resolves "" to the default settings path and a leading ~ to the user's home directory.

func IsMCPTool

func IsMCPTool(name string) bool

IsMCPTool reports whether Claude Code named this tool as MCP-served — the precondition for the cross-surface duplicate rule in dedup.go.

func NormalizeToolName

func NormalizeToolName(name string) (operation, server string)

NormalizeToolName splits Claude Code's `mcp__<server>__<tool>` spelling into the tool name and the server label.

It returns the tool name AS CLAUDE CODE SPELLED IT, which is the sanitised form described on SanitizeClientToolName — not the name on the MCP wire. This used to claim it produced the wire name so that both surfaces would write `operation.name = "refund.issue"`; against a real client that is impossible, because the dot never survives to this surface. What each receipt records is what its own surface observed, and the join reconciles them at read time.

The server label is self-reported and rides as an asserted label, never as identity (Q16, D4).

A local tool (Bash, Edit, Read) passes through unchanged with no server.

func ProxyArgumentsDigest

func ProxyArgumentsDigest(r *receipt.Receipt) string

ProxyArgumentsDigest returns the digest of the raw argument bytes a proxy receipt committed to, read out of its input slot's field-digest manifest, or "".

func SameCrossing

func SameCrossing(proxyR, hookR *receipt.Receipt) bool

SameCrossing reports whether a proxy receipt and a hook receipt describe one trust-boundary crossing.

All four conditions must hold, and each is doing work: the surfaces must differ (or it is not a cross-surface pair), the run must be the same (which in practice means BEHALF_RUN_ID was exported to both, the only rung that makes two surfaces agree — Q7), the two operation names must name the same tool under the client's own name substitution, and the argument bytes must hash the same.

func SanitizeClientToolName

func SanitizeClientToolName(name string) string

SanitizeClientToolName applies the character substitution Claude Code applies when it builds a tool name, so that a name recorded on the MCP wire can be compared against the name the client reported.

Observed in Claude Code 2.1.250 (ENG-33): the client composes an MCP tool name as `mcp__<sanitise(server)>__<sanitise(tool)>` where sanitise replaces every character outside `[A-Za-z0-9_-]` with `_`. A server publishing `refund.issue` therefore reaches the hook as `mcp__payments__refund_issue`.

The substitution is LOSSY and not invertible: `refund_issue` on the wire and `refund.issue` on the wire arrive at this surface as the same string. So nothing here tries to reconstruct the wire name. Each surface records the name it actually saw, and the cross-surface join (dedup.go) compares the two under this function — which is the only comparison that can be made honestly.

func Snippet

func Snippet(o InstallOptions) ([]byte, error)

Snippet renders just behalf's hooks object — what `--print` emits for someone who would rather paste it themselves than let a tool edit their settings file.

Types

type Capture

type Capture struct {
	// contains filtered or unexported fields
}

Capture is an open hook capture surface: everything a single hook invocation needs, loaded once.

func Open

func Open(cfg Config) (*Capture, error)

Open loads the emitter key, policy and chain, and prepares the CAS and the pending store. It does not scan the spool: a hook process must not pay for the whole directory on the agent's hot path (spoolwriter.go).

func (*Capture) Handle

func (c *Capture) Handle(raw []byte) (*Result, error)

Handle captures one hook payload: parse, build the receipt the event calls for, sign it with the emitter key and durably spool it.

It returns ErrUnhandledEvent for a well-formed payload this surface does not receipt. That is not a failure — Claude Code may add events, and recording an unknown one as something it is not would be worse than silence about it.

func (*Capture) Recover

func (c *Capture) Recover(sessionID string, olderThan time.Duration) ([]string, error)

Recover flushes unclaimed pending intents as `orphan_intent` receipts (Q4, Q5): the crossing was recorded as durable intent, the completion never arrived, and the record says exactly that. sessionID limits the sweep to one session; olderThan skips intents younger than the given age, so a sweep run beside a live session does not steal calls that are merely in flight.

type Config

type Config struct {
	// StateDir is the resolved behalf state directory (identity.ResolveDir).
	// It holds the emitter key, the shared monotonic counter, the pending
	// intents, the per-run ordinals and, by default, the spool and the CAS.
	// Required.
	StateDir string
	// SpoolDir defaults to <StateDir>/hook-spool.
	SpoolDir string
	// CASDir defaults to <StateDir>/blobs — the customer-held payload store,
	// shared with every other surface so a blob both surfaces commit to is
	// stored once (Q38's free dedup).
	CASDir string
	// PolicyPath is the tool-policy config; empty uses DefaultPolicyJSON.
	PolicyPath string
	// ChainPath is the delegation chain material; empty means `unattributed`
	// receipts, which is the honest day-zero state.
	ChainPath string
	// Getenv resolves the run_id precedence rungs; nil uses os.Getenv.
	Getenv func(string) string
	// Now overrides the clock; nil uses time.Now.
	Now func() time.Time
	// Entropy overrides the ULID entropy source; nil uses crypto/rand.
	Entropy io.Reader
}

Config configures a Capture.

type Crossing

type Crossing struct {
	// Canonical is the receipt a read surface should render: the proxy's when
	// one exists, because it records the request actually forwarded (Q44).
	Canonical *receipt.Receipt
	// Observations is every receipt describing this crossing, canonical
	// included, in the order given. Nothing is dropped: the collapse is a
	// rendering decision, never a deletion.
	Observations []*receipt.Receipt
}

Crossing is one trust-boundary crossing with every receipt that observed it.

func Collapse

func Collapse(rs []*receipt.Receipt) []Crossing

Collapse groups receipts into crossings, folding each hook receipt onto the proxy receipt it duplicates. Receipt order is preserved and no receipt is ever dropped — a hook receipt with no proxy partner (a Bash call, a consent decision, a session where no proxy ran) is its own crossing.

This is the read-side half of the rule. It exists here, beside the capture code that writes the flag, so the two cannot drift apart.

func (Crossing) Duplicated

func (c Crossing) Duplicated() bool

Duplicated reports whether more than one surface observed this crossing.

func (Crossing) Surfaces

func (c Crossing) Surfaces() []string

Surfaces lists the distinct emitter surfaces that observed this crossing, in observation order.

type Event

type Event struct {
	Raw []byte // the exact stdin bytes

	Name           string `json:"hook_event_name"`
	SessionID      string `json:"session_id"`
	TranscriptRef  string `json:"transcript_path"`
	CWD            string `json:"cwd"`
	PermissionMode string `json:"permission_mode"`

	ToolName  string `json:"tool_name"`
	ToolUseID string `json:"tool_use_id"`

	// Sub-agent identity, self-reported (Q16). SubagentStart/Stop carry these;
	// tool events inside a sub-agent may carry them too.
	AgentID   string `json:"agent_id"`
	AgentType string `json:"agent_type"`

	// PromptID correlates every event from one user prompt to the next
	// (observed; also emitted as the OTel `prompt.id` attribute, which is what
	// makes it a usable run-grouping rung).
	PromptID string `json:"prompt_id"`

	// Reason is the refusal on `PermissionDenied` (required there) and the
	// close reason on `SessionEnd` (one of clear|resume|logout|
	// prompt_input_exit|other). It is the ONLY reason-shaped member either
	// event carries.
	//
	// Checked against Claude Code 2.1.250 (ENG-33). Three fields this struct
	// used to read speculatively — `message`, `permission_decision`,
	// `decision` — do not exist on any hook payload and were removed rather
	// than left implying a check that never ran. `PermissionRequest` in
	// particular carries NO reason of any kind: see the note on
	// handlePermission.
	Reason string `json:"reason"`

	// Error is `PostToolUseFailure`'s failure message, a plain string. This is
	// the real failure signal from this surface — a tool call that fails does
	// not produce a `PostToolUse` at all.
	Error string `json:"error"`
	// IsInterrupt marks a failure caused by the user interrupting rather than
	// by the tool.
	IsInterrupt bool `json:"is_interrupt"`

	// Exact byte spans, absent when the key is absent.
	ToolInputRaw    []byte
	ToolResponseRaw []byte
}

Event is one parsed hook payload.

Parsing is deliberately tolerant and deliberately lossless. Tolerant, because the payload shape is Claude Code's and it moves: fields this struct does not model must not turn a capture into a failure. Lossless, because Raw — the exact stdin bytes — is written to the customer-held CAS and referenced by digest from the receipt (`raw_frame_ref`, Q49), so a field behalf never learned to read is still evidence a reader can go and find.

The three *Raw fields hold exact byte spans out of the payload, extracted without a parse-and-reserialize round trip: they become payload slots whose digests must commit to what Claude Code actually wrote.

func Parse

func Parse(raw []byte) (*Event, error)

Parse reads one hook payload.

func (*Event) DenialReason

func (e *Event) DenialReason() string

DenialReason renders what the payload said about a refusal.

`reason` is REQUIRED on `PermissionDenied` in Claude Code 2.1.250, so the fallback sentence is for a payload from some other producer, not for the normal path.

func (*Event) DenialReasonRaw

func (e *Event) DenialReasonRaw() string

DenialReasonRaw returns whatever reason string the payload carried, or "". Unlike DenialReason it does not substitute a sentence of its own: a session boundary with no reason should record no reason.

func (*Event) Operation

func (e *Event) Operation() (operation, server string)

Operation returns the normalised operation name and the self-reported MCP server label for this event's tool.

func (*Event) ToolInput

func (e *Event) ToolInput() []byte

ToolInput returns the raw tool_input bytes, or an empty JSON object when the payload carried none. An empty object is the honest normalisation: the digest then commits to "no arguments were present", which is a fact, rather than to nothing at all.

type InstallOptions

type InstallOptions struct {
	// Binary is the command Claude Code runs. Required.
	Binary string
	// StateDir, when set, is passed to the capture command so hooks record
	// into the same state directory the proxy and CLI use.
	StateDir string
	// PolicyPath and ChainPath, when set, are passed through.
	PolicyPath string
	ChainPath  string
}

InstallOptions configures the settings merge.

func (InstallOptions) Command

func (o InstallOptions) Command() string

Command renders the command line Claude Code will run.

type InstallResult

type InstallResult struct {
	Path    string
	Added   []string // events that gained our entry
	Updated []string // events whose existing entry we rewrote
	Removed []string // events that lost our entry
	Kept    int      // hook entries belonging to someone else, left alone
	Created bool     // the settings file did not exist
}

InstallResult reports what the merge changed.

func Install

func Install(path string, o InstallOptions) (*InstallResult, error)

Install merges behalf's hook entries into the settings file at path.

func Uninstall

func Uninstall(path string) (*InstallResult, error)

Uninstall removes behalf's hook entries and leaves everything else exactly as it was.

type Pending

type Pending struct {
	IntentID     string `json:"intent_id"`
	IntentDigest string `json:"intent_digest"`

	SessionID string `json:"session_id"`
	ToolUseID string `json:"tool_use_id,omitempty"`

	Operation   string `json:"operation"`               // normalised name (dedup.go)
	RawToolName string `json:"raw_tool_name,omitempty"` // Claude Code's spelling
	MCPServer   string `json:"mcp_server,omitempty"`    // self-reported label (Q16)
	Target      string `json:"target,omitempty"`

	AgentID   string `json:"agent_id,omitempty"`
	AgentType string `json:"agent_type,omitempty"`

	CapturedAt      string `json:"captured_at"`
	EmitterJKT      string `json:"emitter_jkt"`
	EmitterCounter  int    `json:"emitter_counter"`
	RunID           string `json:"run_id"`
	RunIDProvenance string `json:"run_id_provenance"`
	StepKey         string `json:"step_key,omitempty"`
	RiskClass       string `json:"risk_class"`
	RiskPolicyDig   string `json:"risk_policy_digest"`

	InputDigest string `json:"input_digest,omitempty"`
	InputSize   int    `json:"input_size,omitempty"`
	FrameDigest string `json:"frame_digest,omitempty"`
	FrameSize   int    `json:"frame_size,omitempty"`
	ChainRef    string `json:"chain_ref,omitempty"`
}

Pending is one durably-recorded intent awaiting its completion.

Every field is a capture-time fact that cannot be recovered later (receipt-schema-v1.md §9): the counter that was allocated, the policy that classified the call, the run grouping and its provenance, the step key, and the CAS addresses of the bytes the crossing committed to.

type PendingStore

type PendingStore struct {
	// contains filtered or unexported fields
}

PendingStore is the on-disk pending-intent store.

func NewPendingStore

func NewPendingStore(stateDir string) *PendingStore

NewPendingStore returns the store under a behalf state directory.

func (*PendingStore) Claim

func (s *PendingStore) Claim(e *Event) (*Pending, error)

Claim removes and returns the oldest unclaimed intent for this event, or nil when there is none. Both key shapes are tried, so an event that carries a `tool_use_id` still finds an intent recorded before ids were available.

func (*PendingStore) Dir

func (s *PendingStore) Dir() string

Dir returns the store's root directory.

func (*PendingStore) Peek

func (s *PendingStore) Peek(e *Event) (*Pending, error)

Peek returns the oldest unclaimed intent for this event WITHOUT consuming it, or nil when there is none.

An `approval` needs the intent's facts — its digest, its counter's sibling step key, the class the policy assigned — but must not take it: the tool is about to run, and PostToolUse is the event that closes the crossing. Only a denial claims, because after a denial nothing else will.

func (*PendingStore) Put

func (s *PendingStore) Put(e *Event, p Pending) error

Put durably records p under the key for this event. It returns only after the bytes are on the platter: the tool runs after the hook exits, so this fsync is what makes "intent durable before the action" true here.

Why an alias bucket exists

`PermissionRequest` carries NO `tool_use_id`. That was checked against the client's own payload schema in Claude Code 2.1.250 (ENG-33) — the event has `tool_name` and `tool_input` and nothing that identifies the tool call. `PreToolUse` does carry one, so the intent lands in the id-keyed bucket and a later Peek from a permission event, which can only compute the content key, looked in a bucket that was always empty.

The consequence was not a crash and not a missing receipt. It was worse than either: every `approval` receipt anchored to an intent digest it computed itself rather than to the one the tool call recorded, so consent and action carried different digests and the Q5 join a reader is told to use silently never matched. A blank where the evidence should be is exactly what this issue existed to find.

So Put writes the record once, in the id-keyed bucket, and drops a pointer file in the content-keyed bucket beside it. The pointer is a hint and is not fsync'd: losing it costs the join, which is what the code did before, and never costs the record.

func (*PendingStore) Sweep

func (s *PendingStore) Sweep(sessionID string, olderThan time.Duration, now time.Time) ([]Pending, error)

Sweep removes and returns every unclaimed intent matching the filter, oldest first across the whole store. sessionID limits the sweep to one session (what SessionEnd wants); an empty sessionID sweeps all. olderThan drops anything younger than that age, measured from the recorded capture time; a zero duration keeps everything.

type Result

type Result struct {
	Event     string   // the hook event handled
	Kind      string   // the receipt kind minted, "" when none was
	KindExt   string   // the kind_ext stamped, if any
	ReceiptID string   // the minted receipt id
	Counter   int      // the emitter counter the receipt carries
	Pending   bool     // an intent was durably recorded instead of a receipt
	Orphans   []string // receipt ids of orphan_intent records flushed
	Note      string   // a human sentence about anything unusual
}

Result reports what one hook invocation did. It is what the CLI prints on stderr and what the tests assert against; nothing downstream depends on it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL