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
- Variables
- func CrossSurfaceDigest(r *receipt.Receipt) string
- func ExpandPath(path string) (string, error)
- func IsMCPTool(name string) bool
- func NormalizeToolName(name string) (operation, server string)
- func ProxyArgumentsDigest(r *receipt.Receipt) string
- func SameCrossing(proxyR, hookR *receipt.Receipt) bool
- func SanitizeClientToolName(name string) string
- func Snippet(o InstallOptions) ([]byte, error)
- type Capture
- type Config
- type Crossing
- type Event
- type InstallOptions
- type InstallResult
- type Pending
- type PendingStore
- func (s *PendingStore) Claim(e *Event) (*Pending, error)
- func (s *PendingStore) Dir() string
- func (s *PendingStore) Peek(e *Event) (*Pending, error)
- func (s *PendingStore) Put(e *Event, p Pending) error
- func (s *PendingStore) Sweep(sessionID string, olderThan time.Duration, now time.Time) ([]Pending, error)
- type Result
Constants ¶
const ( KindToolCall = "tool_call" KindApproval = "approval" KindDenial = "denial" KindDelegation = "delegation" KindOrphanIntent = "orphan_intent" KindAction = "action" )
Record kinds this surface mints (§3).
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).
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.
const ArgumentsPath = "$.arguments"
ArgumentsPath is the field-digest manifest path the proxy records the raw argument bytes under.
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).
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.
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.
const DefaultSettingsPath = "~/.claude/settings.json"
DefaultSettingsPath is Claude Code's user settings file.
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.
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.
const InstallMarkerValue = "sh.behalf/hook/v1"
InstallMarkerValue is the marker's stable value.
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.
const OrdinalDirName = "hook-runs"
OrdinalDirName holds the per-run causal ordinal counters.
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.
const PendingDirName = "hook-pending"
PendingDirName is the pending-intent store under the state directory.
const PolicyDirName = "policy"
PolicyDirName holds the materialised built-in policy.
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 ¶
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.
var Events = []string{ EventPreToolUse, EventPostToolUse, EventPostToolUseFailed, EventPermissionReq, EventPermissionDenied, EventSubagentStart, EventSubagentStop, EventSessionEnd, EventStop, }
Events is every event this surface installs and handles, in the order the install helper writes them.
var ToolMatcherEvents = map[string]bool{ EventPreToolUse: true, EventPostToolUse: true, EventPostToolUseFailed: true, EventPermissionReq: true, EventPermissionDenied: true, }
ToolMatcherEvents are the events Claude Code scopes with a `matcher`. The rest install without one.
Functions ¶
func CrossSurfaceDigest ¶
CrossSurfaceDigest returns the arguments digest a hook receipt flagged itself with, or "".
func ExpandPath ¶
ExpandPath resolves "" to the default settings path and a leading ~ to the user's home directory.
func IsMCPTool ¶
IsMCPTool reports whether Claude Code named this tool as MCP-served — the precondition for the cross-surface duplicate rule in dedup.go.
func NormalizeToolName ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Duplicated reports whether more than one surface observed this crossing.
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 (*Event) DenialReason ¶
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 ¶
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 ¶
Operation returns the normalised operation name and the self-reported MCP server label for this event's tool.
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.