Help us shape the future of Go! Take the 2026 Go Developer Survey

hooks

package
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package hooks knows how seamark's Claude Code hooks are spelled in .claude/settings.json: the marker strings, the ownership rule, and gate-mode detection live here once — init writes hooks, status reads them, and two copies of the matching logic would drift. The merge that installs the hooks lives here too (merge.go), so init and the client setup adapters share one implementation.

Index

Constants

View Source
const (
	ModeWarn    = "warn"
	ModeEnforce = "enforce"
)

Gate hook modes. Warn installs a hook that follows .seamark/policy.yaml and never blocks by itself; enforce bakes --enforce into the hook, so blocking verdicts exit 2 and the gate's own failures fail closed.

View Source
const (
	CodexLessonsMarker      = "lessons --hook --client codex"
	CodexLessonsResetMarker = "lessons --hook-reset --client codex"
)

The Codex hook commands name their client. A hook command without --client keeps Claude Code semantics, because every installed Claude hook runs exactly that command. Each marker matches as a suffix, so a Claude marker never claims a Codex command and the reverse.

View Source
const LessonsMarker = "lessons --hook"

LessonsMarker is the edit-lessons hook's argument tail.

View Source
const LessonsResetMarker = "lessons --hook-reset"

LessonsResetMarker is the PostCompact hook that starts a new lesson delivery generation for the provider session.

Variables

This section is empty.

Functions

func ChildMap added in v0.7.0

func ChildMap(m map[string]any, key string) (map[string]any, error)

ChildMap returns m[key] as a map and creates it in place when absent. A present value with another type is an error, not an overwrite: setup must never destroy the user's data to install its own.

func ChildSlice added in v0.7.0

func ChildSlice(m map[string]any, key string) ([]any, error)

ChildSlice returns m[key] as a slice, nil when absent, or an error when present with another type, for the same reason as ChildMap.

func ClaudeMatcher added in v0.7.0

func ClaudeMatcher(matcher, tool string) bool

ClaudeMatcher is Claude Code's documented matcher rule (hooks reference, consulted 2026-09-20):

  • "*", an empty matcher, and an omitted matcher fire for every tool.
  • A matcher of only letters, digits, "_", "-", spaces, "," and "|" is a list of exact names. "|" and "," separate the names, and spaces around a name do not count. "Edit" does not fire for MultiEdit.
  • Any other matcher is an unanchored regular expression: it fires when it matches anywhere in the tool name. "Edit$" fires for MultiEdit too.

Claude Code evaluates the expression as JavaScript. A pattern that Go cannot compile, such as one with a lookahead, covers nothing here. A missed duplicate costs a second reminder; a tool wrongly counted as covered would get no handler at all.

func CodexGateMarker added in v0.7.0

func CodexGateMarker(mode string) string

CodexGateMarker returns the Codex gate hook's argument tail for a mode. It is the Claude Code marker plus the client selector, so the mode rule of GateMarker holds for every client: a gate marker starts with GateMarker(mode), and the selector follows.

func CodexMatcher added in v0.7.0

func CodexMatcher(matcher, tool string) bool

CodexMatcher is Codex's documented matcher rule (hooks reference, consulted 2026-09-26):

  • "*", an empty matcher, and an omitted matcher fire for every tool.
  • Any other matcher is a regular expression. The reference shows "^apply_patch$" as an example, so a pattern is unanchored unless it anchors itself.

Codex also matches "Edit" and "Write" as aliases of apply_patch. The rule does not model the aliases: setup reads it for the gate hook on Bash, which has none. A pattern that Go cannot compile covers nothing here, by the rule of ClaudeMatcher. No native run has exercised a matcher other than the exact names that setup writes.

func Covered added in v0.7.0

func Covered(settings map[string]any, spec Spec, fires MatcherRule) (tools []string, command string)

Covered returns the spec's tools that the document really runs a seamark handler for, and the first such command. An entry counts only when the client runs it: a "command"-typed hook whose matcher fires for the tool. Merge is wider on purpose: it rewrites the owned entry under any matcher. Setup uses Covered to report a second hook source that runs the same handler, and an entry that never fires is not a duplicate.

Coverage is per tool, so the report can name exactly the tools that two sources both handle, and the tools that no source handles.

func EffectiveGateMode added in v0.7.0

func EffectiveGateMode(settings map[string]any, gate Spec, fires MatcherRule) string

EffectiveGateMode returns the mode of the gate hooks that the client really runs from one document: enforce when any of them enforces, else warn, else "" when none runs. Enforce wins, because one enforcing hook blocks whatever the other hooks do. The caller passes the gate spec of either mode and of either client; the spec's markers cover both modes, and the mode is read from the marker that owns the command.

func Fires added in v0.7.0

func Fires(spec Spec, matcher string, fires MatcherRule) bool

Fires reports whether an entry with the matcher runs for a tool of the spec under the client's rule. An event without a matcher fires for every entry.

func ForEachCommand

func ForEachCommand(pre []any, fn func(matcher string, h map[string]any, cmd string))

ForEachCommand visits every command hook under one hook-event array, passing each entry's matcher alongside the hook.

func FormatDocumentExact added in v0.7.0

func FormatDocumentExact(document map[string]any) ([]byte, error)

FormatDocumentExact encodes a hook document with two-space indent and a final newline. It does not escape &, <, and >: a hook command such as "a && b" is the user's text, and "a \u0026\u0026 b" is hard to read and to review.

func GateMarker

func GateMarker(mode string) string

GateMarker returns the gate hook's argument tail for a mode. Warn omits --enforce so .seamark/policy.yaml stays the single source of truth for blocking; enforce bakes the flag in, which also makes the gate's own failures block (fail closed).

func GateMarkerMode added in v0.7.0

func GateMarkerMode(marker string) string

GateMarkerMode returns the mode that a gate marker bakes in, or "" for the marker of another hook. Inspection reads the mode of a hook definition through it, whatever wraps the command.

func HookStatusPasses added in v0.7.0

func HookStatusPasses(cmd string, markers []string) bool

HookStatusPasses reports whether the exit status of the seamark hook can become the exit status of the whole command. Claude Code and Codex block a tool call only on exit status 2. A gate hook therefore blocks only when this is true.

The status must pass every level that holds the hook. The levels are the pipeline, the AND-OR list, and the list. Each subshell, brace group, and nested "sh -c" script is a level too. The shell discards the status when it does not wait ("&"). Another command discards it when that command decides the final status, as in "|| true", in "; echo done", and in "| cat" without pipefail. That command counts as one that exits 0, as in SeamarkHookUse, so the verdict does not block. errexit keeps the status, and so do exec and an exit command after "||" or ";". errexit does not apply in a pipeline that "&&" or "||" follows, also inside a brace group or a subshell there. The rules are those of POSIX sh "Shell Commands" and of bash(1) for pipefail and errexit.

The answer is false when the command does not run the hook for any marker. It is true when the reader cannot tell, for example for an unknown wrapper program or unread syntax. A false "discarded" reports a gate that blocks as one that never blocks, and setup treats that as the worse error.

func InstalledGateMode

func InstalledGateMode(settings map[string]any) string

InstalledGateMode reports the mode of seamark's own gate hook in a parsed .claude/settings.json: enforce, warn, or "" when none runs. A gate command counts only when Claude Code runs it for Bash: a "command"-typed hook under a matcher that fires by ClaudeMatcher. Enforce wins, because one enforcing hook blocks whatever the others do.

Setup, inspection, and status read the mode through this one rule. A re-run therefore keeps the mode that doctor and status report. The rule must be ClaudeMatcher: a substring test for "Bash" misses "*", an empty matcher, and an expression such as "Ba.*".

func IsSeamarkBinary added in v0.6.0

func IsSeamarkBinary(command string) bool

IsSeamarkBinary reports whether a command path names the seamark binary: exact basename "seamark", tolerating the Windows suffix. The hook owner, the Claude Code registration, and the Codex registration all use this one rule, so they agree on what is seamark's.

func ManagedRuns added in v0.7.0

func ManagedRuns(settings map[string]any, spec Spec, bin string, fires MatcherRule) bool

ManagedRuns reports whether the client runs the managed command of the spec, spec.Command(bin), for a tool of the spec: a "command"-typed entry with exactly that command under a matcher that fires. The check names the command that Merge wrote, so it holds for any binary path, also one whose basename OwnedBySeamark does not accept. An entry that Merge kept under a matcher that never fires is not a running hook.

func Owned added in v0.7.0

func Owned(settings map[string]any, spec Spec) bool

Owned reports whether the document holds seamark's own command for the spec, in any entry of the spec's event.

func OwnedBySeamark

func OwnedBySeamark(cmd string, markers []string) bool

OwnedBySeamark reports whether cmd is one of seamark's own hook commands: exactly one shell word that names the seamark binary, then a marker, and nothing else. Setup rewrites a command it owns, so the rule must be strict in both directions.

The binary check keeps the marker from claiming someone else's hook: `company-security gate --hook` (or a lookalike such as `seamark2`) is not ours. The one-word check keeps setup from destroying a wrapper: `/opt/wrapper /usr/local/bin/seamark gate --hook` ends like our command, and a rewrite to the bare command removes the wrapper without a word.

func ParseDocument added in v0.7.0

func ParseDocument(data []byte) (map[string]any, error)

ParseDocument decodes one JSON hook document without naming a file in the error, so a caller that reads another file, such as .claude/settings.local.json, can name the right one. A top-level null is an empty document: it holds nothing, and a nil map would panic on the first merge.

func ParseDocumentExact added in v0.7.0

func ParseDocumentExact(data []byte) (map[string]any, error)

ParseDocumentExact is ParseDocument for a document that setup writes back. It keeps every number as written: the default decoder reads a number as a float64, which rounds an integer above 2^53 and turns 1.0 into 1. FormatDocumentExact is the matching encoder.

The Claude Code settings keep ParseDocument and their own encoder, because a file that init wrote before must stay byte-stable.

func ParseSettings added in v0.7.0

func ParseSettings(data []byte) (map[string]any, error)

ParseSettings decodes the bytes of .claude/settings.json. The setup planner reads the file once under a guard and parses those same bytes, so the plan and its guard always describe one file state. An empty file is malformed JSON, as it always was; only a missing file is an empty map, and the caller decides that.

func ReadSettings

func ReadSettings(root string) (map[string]any, error)

ReadSettings loads <root>/.claude/settings.json; a missing file is an empty map, an unparseable one an error.

func ShellQuote added in v0.7.0

func ShellQuote(s string) string

ShellQuote single-quotes a path that a shell would split or interpret. A clean path returns unchanged. Clients run hook commands through a shell, so a binary path with spaces needs the quotes.

Types

type HookReading added in v0.7.0

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

HookReading is the reading of one hook command for the markers of one hook. Setup and inspection ask several questions about one command: whether it runs the hook, for which marker, and whether the exit status passes. One reading parses the command once and answers them all, so the answers come from one parse.

func ReadHook added in v0.7.0

func ReadHook(cmd string, markers []string) HookReading

ReadHook reads a hook command for the markers of one hook, by the rules of SeamarkHookUse and HookStatusPasses.

func (HookReading) StatusPasses added in v0.7.0

func (r HookReading) StatusPasses() bool

StatusPasses is the answer of HookStatusPasses: the exit status of some run of the hook can become the exit status of the command.

func (HookReading) StatusPassesFor added in v0.7.0

func (r HookReading) StatusPassesFor(marker string) bool

StatusPassesFor is StatusPasses for one marker.

func (HookReading) Use added in v0.7.0

func (r HookReading) Use() HookUse

Use is the answer of SeamarkHookUse: the strongest use over the markers.

func (HookReading) UseFor added in v0.7.0

func (r HookReading) UseFor(marker string) HookUse

UseFor is the use of one marker: HookNotRun for a marker that the reading does not hold.

type HookUse added in v0.7.0

type HookUse int

HookUse says whether a hook command runs a seamark hook.

const (
	// HookNotRun means the command does not run the hook. The hook text
	// can still occur in it, for example as an argument of echo or in a
	// comment.
	HookNotRun HookUse = iota
	// HookMayRun means the command can run the hook, and the words do not
	// show that it always does. For example, another program gets the
	// seamark command as arguments, or the hook runs only on a condition
	// or in the background. The hook can also read another input than the
	// client's payload, or the command uses syntax that this package does
	// not read.
	HookMayRun
	// HookRuns means the shell executes the seamark binary with exactly
	// the hook arguments, in the foreground, each time the command runs.
	// The hook reads the client's payload. Its output and its exit status
	// can still go to another program; HookStatusPasses tells the second.
	HookRuns
)

The answers of SeamarkHookUse.

func SeamarkHookUse added in v0.7.0

func SeamarkHookUse(cmd string, markers []string) HookUse

SeamarkHookUse reads a hook command the way a shell does. It splits the command into words with the quoting rules and removes comments. Then it parses the words into lists, AND-OR lists, pipelines, subshells, and brace groups. The answer depends on what the shell executes, not on which text occurs.

The two wrong answers do not cost the same. A false HookRuns makes setup install nothing, and the user loses the hook. A false HookNotRun makes setup install a second handler, and nothing warns about it. The function therefore answers HookRuns only for the forms it fully reads. It answers HookMayRun for the rest, which keeps the install and names the command in a warning. The rest includes an expansion such as "$x", a here-document, and a compound command such as "if" or "for". It also includes a redirection that the shells read in different ways, such as "&>", and a command that is too long or too deep for the parser.

A command other than the hook counts as one that exits 0 and does not read standard input. A guard such as "test -x seamark" or "cd /repo" normally does that. A hook after "&&" therefore runs, and a second handler beside it would run each time too. A hook after "||" runs only when the command before it fails, so it is HookMayRun. With errexit, a guard before the hook does not end the shell, by the same rule.

A hook in the background ("seamark ... &") is HookMayRun, because the shell does not wait and the client can miss the output. A hook that reads the output of another program ("x | seamark ...") or a file ("seamark ... < f") is HookMayRun too. Its input is not the client's payload, and only the other program decides what the hook gets.

A hook whose output or exit status goes elsewhere is still HookRuns. "seamark ... | tee log", "seamark ... > log", and "seamark ... || true" run it each time with the client's payload. The user chose where the output goes, so setup does not add a handler beside it. A gate verdict blocks only by exit status 2, so HookStatusPasses answers that question.

A marker matches only as the complete argument list. "lessons --hook" is a prefix of the Codex marker and of the reset marker, and a longer argument list is another hook.

type MatcherRule added in v0.7.0

type MatcherRule func(matcher, tool string) bool

MatcherRule reports whether an installed matcher fires for one tool. The rule is the client's, not seamark's: each client documents its own matcher syntax, so the caller passes the rule of the client that reads the document.

type Merged added in v0.7.0

type Merged struct {
	// Changed is true when the document changed.
	Changed bool
	// Rewritten lists each owned command that Merge replaced, in
	// document order.
	Rewritten []Rewrite
}

Merged reports what Merge did to a document. The narration of a setup run reads it, so the narration describes the rewrite that Merge made, not one that the narrator works out again.

func Merge added in v0.7.0

func Merge(settings map[string]any, bin string, specs []Spec) (Merged, error)

Merge adds the hooks into a parsed hook document. It keeps every other hook, and it updates a seamark hook that is already present, so a re-run never adds a duplicate. It reports whether the document changed and which owned commands it rewrote. A "hooks" field or an event field with the wrong type is an error: init must never overwrite the user's data to install a hook.

func (Merged) RemovesEnforce added in v0.7.0

func (m Merged) RemovesEnforce(enforceMarker string) bool

RemovesEnforce reports whether a rewrite replaced a command with the enforce marker by one without it. Setup narrates such a rewrite, because a plain re-run can remove a flag that the user wrote. The caller passes the enforce marker of its client.

type Rewrite added in v0.7.0

type Rewrite struct {
	// From is the command as the document held it.
	From string
	// To is the command that Merge wrote in its place.
	To string
}

Rewrite is one owned command that Merge replaced with the command of a spec.

type Spec added in v0.7.0

type Spec struct {
	// Name is the short name of the hook for narration: "gate",
	// "lessons", or "context reset". A file line names what it wrote
	// from the specs it merged, so the name lives with the spec.
	Name string
	// Event is the native event name, for example "PreToolUse".
	Event string
	// Matcher is the native tool matcher. Empty means the event has none.
	Matcher string
	// Marker is the command's argument tail, everything after the binary
	// path. Merge matches it as a suffix, with the joining space, so an
	// existing seamark hook is recognized after the binary path changes.
	// An unrelated command that only contains the same text never matches.
	Marker string
	// Legacy lists older marker spellings of the same hook. Merge
	// recognizes them like Marker and rewrites them to it, so a re-run
	// migrates an existing hook and never adds a duplicate beside it.
	Legacy []string
	// Status is the message the client shows while the hook runs.
	Status string
	// Timeout is the hook timeout in seconds.
	Timeout int
}

Spec is one lifecycle hook seamark installs into a client's hook document. The document shape is the one Claude Code and Codex share: an event name that maps to entries, each with a matcher and a list of command hooks.

func ClaudeSpecs added in v0.7.0

func ClaudeSpecs(gateMode string) []Spec

ClaudeSpecs returns the hooks init installs into Claude Code for a gate mode. The opposite mode's marker is listed as legacy, so a mode switch rewrites the existing gate hook in place.

func CodexSpecs added in v0.7.0

func CodexSpecs(gateMode string) []Spec

CodexSpecs returns the hooks that setup installs into Codex for a gate mode. The gate hook comes first, as in ClaudeSpecs, so a caller finds it at index 0. Codex names its shell tool Bash in the event and in the matcher; the opposite mode's marker is listed as legacy, so a mode switch rewrites the existing gate hook in place. Codex reports every file edit as the apply_patch tool. A matcher is a regular expression, and "apply_patch" also matches the documented aliases Edit and Write. The timeout is seconds, as in Claude Code.

The list holds no context-reset hook. The Codex edit decoder reads no receiver yet: the event names one, but the reset of a subagent is unverified, so suppression stays off by policy and a reset has nothing to clear. A hook without an effect still costs the user a trust review and one process for each compaction. The reset command and its decoder exist, and the hook joins the list when the edit decoder names a receiver with a verified reset.

func (Spec) Command added in v0.7.0

func (s Spec) Command(bin string) string

Command returns the full hook command for a binary path.

func (Spec) Markers added in v0.7.0

func (s Spec) Markers() []string

Markers returns every spelling that identifies the hook: the current marker first, then the legacy ones.

func (Spec) Tools added in v0.7.0

func (s Spec) Tools() []string

Tools returns the tool names the matcher lists. A spec without a matcher returns one empty name, which stands for the event itself, so coverage is computed the same way for both kinds of hook.

Jump to

Keyboard shortcuts

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