Documentation
¶
Overview ¶
Package hook implements the three Claude Code lifecycle hooks of plan 6.3 — `brigade hook session-start`, `brigade hook prompt` and `brigade hook session-end` — the processes Claude Code runs from plugin/hooks/hooks.json with a JSON document on stdin and the session's environment (6.5).
The hooks are the only code that reads the CLAUDE_PLUGIN_OPTION_* values, resolves the profile and adapter (D36), registers the Brigade session and writes the by-pid and by-native maps every other command reads (3.2), and they are the only legitimate producer of the detached watcher's environment (6.6): the messaging token reaches the watcher ONLY through the environment the hook builds, is never written to a file (its SHA-256 in the pidfile is the one derived form), never put on argv, never logged and never handed to an adapter child.
Every subcommand exits 0 on every failure, with one stderr diagnostic through the redacting logger and, where useful, a context line on stdout: a non-zero exit from the prompt hook would block and erase the user's prompt, and SessionStart/SessionEnd cannot block at all (6.3). Nothing a hook prints carries a remote string unsanitised: team and session names go through protocol.SanitizeAttribute (quotes, angle brackets and newlines dropped, 64 code points), because hook stdout is attached to the user's own turn with no harness preamble.
Inside a session every inherited BRIGADE_* variable is ignored (U-27): the configuration comes from the options, the by-pid map and the CLAUDE_* facts alone, all read through internal/harness/config and adapterkit.Getenv.
Every side effect is injectable through Deps — the clock, the watcher Spawner, the registry fs.FS, the settings reader, the adapter spawn seam, the process-facts lookup and the PATH search — so the tests run hermetically against the fake adapter, a fake registry, a temp XDG triple and a reaped sleeper as CLAUDE_PID.
Index ¶
Constants ¶
const ( SubSessionStart = "session-start" SubPrompt = "prompt" SubSessionEnd = "session-end" )
The three subcommands (6.3, hooks.json).
const ( // DefaultPidfileWait is how long the hook waits for a freshly spawned // watcher to write its pidfile (6.6) before it logs and moves on; the // next prompt respawns a watcher that never appeared. DefaultPidfileWait = 2 * time.Second // OutputCap is the 10,000-character hook-output cap of 6.3: frames // are printed until it would be exceeded, and only printed frames are // acknowledged. OutputCap = 10000 )
The hook's budgets. The per-call adapter budgets are adapterclient's 4.1 constants; these bound the whole hook run under the timeouts hooks.json declares (60 s, 5 s, 5 s — the SessionEnd budget is really 1.5 s shared across every SessionEnd hook, E0-5 (h)).
Variables ¶
This section is empty.
Functions ¶
func Run ¶
Run executes one `brigade hook <subcommand>` invocation and returns the process exit status. args excludes the words `brigade hook`; the only flag is --log-level. environ is the process environment in os.Environ form. Every subcommand returns 0 on every failure (6.3); the one non-zero answer is `usage` (2) for a missing or unknown subcommand or flag, which no hooks.json ever produces.
Types ¶
type Deps ¶
type Deps struct {
// Now is the clock for every timestamp the hook records (map times,
// the prune stamp, the retry stamp) and for the pipeline's rate
// window; nil means time.Now. Real waits (for a pidfile) use the
// system clock regardless.
Now func() time.Time
// Spawner starts the watcher; nil means RealSpawner.
Spawner Spawner
// Registry is the fs.FS the session registry is read from; nil means
// registry.Dir(config.ClaudeConfigDir(environ)) per run.
Registry fs.FS
// ReadFile reads the three settings files of the native scan (6.10),
// the candidate settings files of the doing-rules scan (card 25, plan
// 5.2) and, at SessionStart, the frame_file option's file (P5-12);
// nil means os.ReadFile.
ReadFile func(string) ([]byte, error)
// WriteFile is the ONE write under the Claude Code configuration
// directory (card 50, policy.EnsureUserAccept); nil means
// adapterkit.WriteAtomicMode.
WriteFile func(path string, data []byte, mode os.FileMode) error
// Spawn is the adapter request/response seam (adapterclient.Client.
// Spawn); nil means adapterkit.Spawn, a real child.
Spawn adapterclient.SpawnFunc
// Lookup reads process facts for the pidfile guard; nil means
// procutil.Lookup.
Lookup pidfile.LookupFunc
// LookPath searches a PATH value for an executable name (the
// shadowing check of 6.2); nil means the package's own search.
LookPath func(pathVar, name string) (string, bool)
// SoundPlayer resolves the fixed argv of the sound player the
// `message_sound` option would run on this machine, or nil and why
// not (card 35, notify.Sound), and Notifier the same for the desktop
// notifier of `message_notification` (card 36, notify.Banner); nil
// means the package's own probe. The hook never runs either:
// SessionStart only says when one cannot be run.
SoundPlayer func(pathVar string) ([]string, string)
Notifier func(pathVar string) ([]string, string)
// BrigadeVersion is this binary's own version as a registration
// reports it (brigade_version, C-46); nil means buildinfo.Claimed,
// which is nil itself for a binary with no version to claim.
BrigadeVersion func() *string
// SyncPeer is the folder-sync peer descriptor a registration and the
// start heartbeat report when the hook knows one (sync_peer, C-47);
// nil means it knows none, and the member is then absent — never
// cleared. At SessionStart it usually knows none: the watcher's sync
// adapter attaches after the registration and the watcher's first
// heartbeat after that carries the peer (plan folder-sync.md 4.2).
SyncPeer func() *string
// Sink, when set, is passed to the watcher as `--sink <file>` (6.6).
// Only a test harness sets it; production never does.
Sink string
// PidfileWait bounds the wait for a spawned watcher's pidfile; zero
// means DefaultPidfileWait.
PidfileWait time.Duration
// PromptBudget bounds the whole prompt-hook run; zero means
// promptBudget. Only a test lowers it, to reach the doing reminder's
// budget guard (plan 5.4) without a real 3.5 s stall.
PromptBudget time.Duration
}
Deps are the injectable side effects of a hook run. RealDeps returns the production set; a zero field means the production default, so a test overrides only what it needs.
type RealSpawner ¶
type RealSpawner struct{}
RealSpawner starts the detached watcher for real (6.6): stdin from the null device, stdout and stderr to the log file, its own session and process group (Setsid), no controlling terminal, the given environment and nothing else; started, released, never waited for.
type SpawnSpec ¶
type SpawnSpec struct {
// Args is the argv after the program name: "watch", plus "--sink
// <file>" when Deps.Sink is set.
Args []string
// Env is the child's whole environment, built from scratch by
// watcherEnviron. The messaging token is in here and nowhere else.
Env []string
// Dir is the child's working directory (the user's HOME).
Dir string
// LogPath is the 0600 append-mode file both of the child's output
// streams go to: ${stateDir}/logs/watcher-<claude_pid>.log.
LogPath string
}
A SpawnSpec is everything the watcher child is started with. The executable is this binary (os.Executable) and is not a member: a map or an option must never be able to name the program the hook detaches.
type Spawner ¶
type Spawner interface {
// Spawn starts `brigade watch` per spec and returns its pid. It never
// waits for the child.
Spawn(ctx context.Context, spec SpawnSpec) (int, error)
}
A Spawner starts the detached watcher (6.6). RealSpawner is the one exec.CommandContext of this package (spawn.go); tests inject a recorder.