How it wires together — the moments
You author against the work loop, not against hooks. One shared key — at: — names the moment on both signs and rules; the engine owns which hook actually delivers it, so nobody authoring a bundle ever names a PreToolUse.
The moments
| Moment | Kind | What happens |
|---|---|---|
session |
steer | Every bundle’s one orientation sign, composed and injected at session start (and after resume, /clear, compaction) — the composed CLAUDE.md. |
touch |
steer | An area sign fires when the agent reads / writes / runs in its path or command (the default for signs). |
write |
block | A rule blocks the edit before it lands — the deny reason reaches the model and re-guides it. |
commit |
block | The gate — rules run over the whole staged change, once, with no agent in the loop. |
delete |
block | A rule guards the targets of rm / git rm / mv before they run. |
turn |
block | End of every turn — hold it open until something is done (use sparingly; prefer commit). |
Rules default to at: [write, commit] — one line drives both the pre-emptive block and the commit gate. Signs default to at: touch. The platform itself forces the split: Claude Code’s hook API has no “advise but allow” before a tool runs, so steer-after-read and block-at-write are the only two mid-session shapes possible — the vocabulary makes that honest.
write · the pre-emptive block
The file never touches disk. The deny message is fed back to the agent, which retries correctly.
commit · the backstop
session + touch · steering context
Orientation is composed from every bundle’s at: session sign; area signs re-inject every drift_tokens as context grows.
turn · the end-of-turn check
Never traps: Claude Code’s own stop_hook_active means a blocked turn nags at most once, then ends. Reach for turn only when the turn boundary is genuinely the right moment — most checks belong at commit.
The moment → hook mapping the engine owns
| Moment | Delivered by |
|---|---|
session |
SessionStart (matchers: startup · resume · clear · compact). PostCompact still re-briefs area signs. |
touch |
PostToolUse (Read/Edit/Write/Glob/Grep/Bash) |
write |
PreToolUse deny (Edit/Write) |
delete |
PreToolUse on Bash (rm / git rm / mv targets) |
commit |
.githooks/pre-commit (via core.hooksPath) |
turn |
Stop (and SubagentStop for agents) |
Backwards-compatible sugar: global: true reads as at: session; the old when: reads as at: (with write mapping to the internal edit phase).
The off switch
npx signposts off writes one gitignored marker (.signposts/off) that every rail checks first and exits instantly — no sign injection, no pre-emptive block, no command guard, no commit gate. npx signposts on restores them. The point is honest A/B testing: run the same task with the rails on and off and compare. Status shows in the CLI so a silenced project can’t masquerade as a guarded one.
The engine keeps its own event log
Every evaluation appends one line to a shared, gitignored log at ~/.signposts/<repo-key>/log/<session>.jsonl — keyed by repo identity, so a repo’s git worktrees share one ledger (a repo-local .signposts/log/ is the fallback for non-git dirs): a run with per-rule tallies, a check per matched file, a deny per violation, and a sign per injection. It’s the deterministic ground truth behind npx signposts facts and its HTML report card — per-rule matched vs blocked, rules that never fired, plus the composed-orientation size so it can’t creep silently. Writing it is fail-safe: an unwritable log can never block an edit.
Two things that always hold
- Fail-safe. If a script or the hook itself errors, it exits cleanly and lets the edit through. A broken rule can never wedge your agent — it just stops enforcing until fixed.
- All local, in-process. Rules run inside the hook (no temp files, nothing sent anywhere). The pre-emptive check is fast enough to sit on every write — which is why ast-grep is a core script, not a tool-gate. A whole-project tool can’t block pre-write, so it’s commit-only.