# Session hooks — `mgf-fed hooks`

> **Status: observe mode.** What ships today prints and records. It never blocks a
> session, on any path, for any reason. The enforcing half exists in the package and
> is switched **off**; the boundary between them is §4, and it is the most important
> section on this page.

Before this landed, mgf repos had **no session hooks at all**. Project instructions
load every session and the system prompt names the model, so a session-start hook is
the one channel that reaches *every* agent session mechanically — regardless of what
any agent remembers, and regardless of which model is driving. The layering it
belongs to:

> **CI enforces truth. Documents communicate intent. Hooks guarantee the
> communication happens.**

---

## 1. What it does

`mgf-fed hooks install` merges **one line** into a repo's `.claude/settings.json`:

```sh
command -v mgf-fed >/dev/null 2>&1 && { mgf-fed hooks session-start || echo '…' >&2; }; exit 0
```

(the `…` is the one-line staleness notice — see §2's ordering rule for the exact text)

Nothing else is written. No scripts are copied, no kit is vendored, no directory is
created beyond `.claude/`. At every session start the harness runs that line, and it:

1. **Announces the kill-switch** if the kit is switched off (§5). An OFF protocol
   that looks healthy is the invisible failure the kit exists to end.
2. **Records that a session existed** — once per session id — in
   `.claude/state/agent-events.jsonl`.
3. **Re-teaches [SEAT-02](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/SEATS.md)**:
   name your seat before you act, with the exact command to record it.

And one thing it deliberately does **not** do:

> **The hook never declares the seat for the agent.** It cannot — only the session
> can read its own system prompt — and a fabricated `model=unknown` record would
> poison the very audit trail the log exists to create. *"Automatic"* means the
> instruction reaches every session mechanically and that **non-declaration is
> visible in the log**, not that the record is invented.

The undeclared block an agent actually sees:

```
## SEAT-02 — this session has NOT declared its seat. Declare it BEFORE you act.
   Fable / Mythos-class        -> designer  contracts, verdicts, rulings (SEAT-03)
   Opus / Sonnet / Haiku-class -> executor  implement against a ruling (SEAT-06)
   the founder (human)         -> enactor   the decision is his alone (SEAT-01)
   Declare:  mgf-fed hooks emit seat_declared seat=<designer|executor> model=<class> task_class=<...>
   SEAT-04: every subagent / workflow spawn this session MUST pin an explicit model.
   SEAT-05: end with your thinking in a COMMITTED document, committed as each artifact finishes.
   This hook CANNOT declare for you — it cannot read your system prompt, and a
   fabricated record would poison the audit trail. The MISSING record IS the finding.
```

A session that *has* declared gets one quiet line instead. A re-teach that never
shuts up is noise, and noise is what a reader learns to scroll past.

## 2. Using it

### Ordering — upgrade the fleet BEFORE you install

> **Upgrade `mgf-fed` everywhere the repo will be opened, and only then run
> `mgf-fed hooks install`.**

The shim names a verb (`mgf-fed hooks session-start`). It is written into a **repo**,
but it is served by whichever `mgf-fed` happens to be on `PATH` in whichever
**environment** opens that repo — a different venv, a different machine, a colleague's
laptop, a `uv tool` install nobody has refreshed since March. Installing first and
upgrading later inverts the dependency and leaves a window in which the shim is
present and cannot be served.

The window is bounded, not silent. Three cases, deliberately distinguished:

| in that environment | what happens |
|---|---|
| no `mgf-fed` at all | the `command -v` guard skips silently and the session starts normally — absence is the operator's choice, not a fault |
| `mgf-fed` present, too old for `hooks` | **one line on stderr**: `mgf-fed hooks: session-start unavailable (mgf-fed too old or broken here) — upgrade mgf-fed, then run: mgf-fed hooks status` |
| `mgf-fed` current | the re-teach runs |

One line, once, no retry loop — a notice that repeats is a notice people learn to
scroll past, and this one has to still be legible on the day it matters. Either way
the session starts: the shim ends in `; exit 0`.

`mgf-fed hooks status` is the check to run after upgrading — it reports whether the
shim is present *and* whether it is the current text.

### The commands

```bash
mgf-fed hooks status                    # is the shim wired? current? switched off?
mgf-fed hooks install                   # merge the shim into .claude/settings.json
mgf-fed hooks uninstall                 # remove exactly our entry, leave the rest
mgf-fed hooks emit seat_declared seat=executor model=opus-5 task_class=implementation
mgf-fed hooks log --tail 20             # read the trail back (--session / --json)
```

`--repo PATH` targets another checkout; the default is the current directory's repo
root. `status --json` and `log --json` are machine-readable.

After installing, add these to `.gitignore`:

```gitignore
.claude/state/
.claude/mgf-hooks-disabled
```

The second one is not optional hygiene. A sweeping `git add` that commits the
kill-switch would switch the kit off for **every clone**, permanently and invisibly —
the one failure a kill-switch may never have. Repos that *develop* mgf-fed itself
also ignore `src/mgf/fed/sessionhooks/DISABLED`, where an editable install resolves
the fleet-wide switch: committing that one would ship a permanently-disabled kit to
every consumer in the next wheel.

## 3. The event log

`.claude/state/agent-events.jsonl` — append-only, one JSON object per line, local to
the repo the agent is working in. Readable in realtime (`tail -f`) or after the fact
(`mgf-fed hooks log`).

**The vocabulary is closed**, and growth is a smell. A log that accepts any event name
is a log nobody can query and nobody trusts, so the refusal path is as much of a
feature as the append path:

| event | means |
|---|---|
| `session_start` | a session opened in this repo |
| `seat_declared` | SEAT-02: the session named its seat before acting |
| `seat_override` | SEAT-03/06: out-of-seat work, with the recorded reason |
| `escalation` | a standard-defined trigger fired (`no_playbook` · `telemetry_unreachable` · `hypotheses_refuted` · `cross_repo_blast`) |
| `journal_step` | SEAT-05: a completed step a resuming session can pick up from |
| `context_ambiguity_filed` | ambiguous auto-loaded context **filed**, not silently absorbed |

`seat` is validated against SEAT-01's three seats (`designer` / `executor` /
`enactor`). Everything else is refused with a reason and a non-zero exit — and
refusal never writes anything.

Four invariants the log holds under stress:

* **A torn write never breaks the reader.** A crash mid-append leaves a partial last
  line; it is skipped, not fatal. An audit trail that becomes unreadable at the first
  bad byte is not an audit trail.
* **A persistence failure never changes behaviour.** A full disk produces a warning
  and the hook carries on — including a full *stdout*, which is the one that can
  actually stop a session (`> /dev/full` is the repro). Observability must never
  become the failure.
* **Hook-path reads are bounded to the last 256 KiB.** The log is append-only and
  unbounded, so an unbounded read makes starting a session cost more forever as the
  repo ages, and a hook that gets slower forever is a hook that gets uninstalled.
  256 KiB is ~1,500 records — far more than one session writes. The consequence,
  stated plainly: a `seat_declared` older than that window reads as *absent*, costing
  one extra re-teach line. `mgf-fed hooks log` is unbounded; a human asking for the
  trail gets the trail.
* **"Once per session" is claimed with `O_CREAT|O_EXCL`, not by reading.**
  `SessionStart` fires on startup *and* resume *and* compact, and those can run
  concurrently — a read-then-write check is a race that loses under exactly the load
  that produces duplicates.

## 4. The observe / enforce boundary

**Two independent keys** stand between a fresh install and a session that can be
blocked. This is deliberate: *no step may create a state that is only safe if a later
step also happens.*

| `MGF_HOOKS_GATE` | `MGF_HOOKS_MODE` | behaviour |
|---|---|---|
| unset (**default**) | anything | The gate and write-set recorder return `0` immediately, having read nothing and written nothing. **This is what `install` leaves behind** — a SessionStart hook and nothing else. |
| `1` | unset / `observe` | Everything is evaluated; **nothing** blocks. Findings are written to `.claude/state/mgf-hooks-pending.jsonl` and replayed into the next session's context. |
| `1` | `enforce` | A **blocking** finding exits `2` with its reason on stderr — *once*. On the retry the harness sets `stop_hook_active` and the gate lets the session out. |

Note the middle row's mechanism: a stop-time hook's output is **discarded by the
harness when it does not block**, so a gate that speaks only when blocking is
inaudible in exactly the mode built for *"this runs where nobody is watching."* The
verdict is therefore persisted and read back at the next session start — once, not
forever.

Note the third row's release valve too: a gate that can trap a session is a gate that
gets ripped out.

### Which findings may block

| finding | blocks? | why |
|---|---|---|
| `seat_undeclared` (SEAT-02) | **yes**, when armed *and* enforcing | It has a sanctioned, one-command exit: declare. The gate blocks **silence**, never **judgment** — and a gate with no sanctioned exit is a gate that gets bypassed. |
| `cross_repo_write` (attribution) | **never**, in either mode | mgf has **not ruled a cross-repo handoff channel**. Blocking on a finding whose discharge does not exist would be a gate with no exit the moment it fired. It is recorded, reported, and left advisory. |

**Ruling that channel is the named prerequisite** for making the second row
enforceable. That is a corpus decision, not a code change — this repo does not take
it. Until then the gate half is exactly what its name says: staged material.

The write-set recorder that feeds it holds one distinction worth stating plainly.
A path from `Edit`/`Write`/`MultiEdit`/`NotebookEdit` is **definitive** — this session
wrote that file. A shell command is **evidence a human can read**, never an
attribution: a command can write anywhere and proves nothing directly. Without that
distinction a gate compares a repo to its baseline and blames this session for any
difference, *including a concurrent session's legitimate work* — and blaming an
innocent session, then ordering it to document work it never did, is how a discipline
gets ripped out.

## 5. The kill-switch

Three scopes, checked **before any other logic**, first match wins. While disabled
nothing is recorded and nothing can block — ever — and the disabled state is
**announced** at session start.

| scope | how |
|---|---|
| one session | `MGF_HOOKS_DISABLED=1` in the environment |
| the whole fleet | create the `DISABLED` file next to the installed package (`mgf/fed/sessionhooks/DISABLED`) — git-ignored in this repo, and pinned absent from the built wheel |
| one repo | `touch .claude/mgf-hooks-disabled` |

`MGF_HOOKS_DISABLED=0` reads as *off the switch*, not on it. Rollback of the whole
thing is `mgf-fed hooks uninstall`, or one commit reverting that repo's
`settings.json`.

## 6. Environment reference

| variable | default | meaning |
|---|---|---|
| `MGF_HOOKS_DISABLED` | unset | kill-switch, session scope |
| `MGF_HOOKS_DISABLE_FILE` | *(package dir)*`/DISABLED` | fleet kill-switch path (tests override it) |
| `MGF_HOOKS_STATE_DIR` | `<repo>/.claude/state` | where state is written (tests override it) |
| `MGF_HOOKS_MODE` | `observe` | `observe` \| `enforce`. Anything unrecognised reads as `observe` — a typo in the mode must never be read as "enforce". |
| `MGF_HOOKS_GATE` | unset | the literal `1` — and only `1` — arms the staged gate half. `true`/`yes`/`on`/a typo read as **unarmed**, for the same reason `MGF_HOOKS_MODE` does: a mistake here must fall towards not blocking. |
| `CLAUDE_SESSION_ID` | — | the harness's session id, used when stdin carries none |

## 7. Provenance

**Adapted with attribution from the Sooth family's session-hook kit**
(`sooth-standard/shp/`, SHP-01..12) — the fail-open discipline, the closed event
vocabulary, the kill-switch at three scopes, the observe→enforce staging, the
attribution-before-blame rule, and the "the hook never declares for the agent" note.

**This is a copy, not a dependency.** mgf takes on no import edge, no package
dependency, and no cross-family resolution path to the Sooth corpus. The two families
are independently versioned and may diverge; rule ids are kept citable across both,
and the text, paths, environment prefixes, and rule references here are mgf's.

Four deliberate divergences from the source, each with its reason:

1. **One line, not four shims.** Sooth copied four shell scripts into every adopting
   repo and resolved a sibling checkout at run time, because its kit was a directory.
   mgf's kit is an installed package, so adoption collapses to a single
   `settings.json` command and there is nothing to keep in sync.
2. **The owning repo, not a registry.** Sooth resolved sibling repos **from a
   registry over a shared family parent** — its rules explicitly *forbid* globbing
   that parent (it holds dozens of non-family repos), so the registry is the
   correction rather than the flaw. This port asks a narrower question and needs
   neither: it walks each written path up to its owning git repo — exact rather than
   enumerated, needing no registry, no `MGF_ROOT`, and no subprocess, and
   structurally incapable of the "zero siblings resolved, so the gate silently
   protects nothing" failure Sooth had to add a tripwire for.
3. **A native verb, not a registry-merged one.** Sooth's kit was reached by execing
   a file; mgf's is reached by a CLI verb, and `mgf-fed`'s governance verbs normally
   arrive through a subcommand registry that requires the `[cli]` extra. `hooks` is
   wired natively instead, so a bare `pip install mgf-fed` still serves the shim — a
   hook path that depends on an optional extra is a hook path that silently does not
   exist for part of the fleet.
4. **`session_start` in, `preflight` out.** The absence of a `seat_declared` record is
   only visible against a record that the session existed at all; Sooth got that from
   its per-session `preflight` event, which mgf has no equivalent of on this path. An
   event type nobody emits is dead vocabulary, so `preflight` was dropped rather than
   carried.
