Hooks
Synthex installs seven hooks on Claude Code and one on Codex. Each one is a shell script in
plugins/synthex/scripts/. Every hook fails open: if it can't read its input, finds a
tool missing, or hits an error, it exits quietly and lets the session carry on.
Summary
| Event | Script | Host | Can block? | On by default? |
|---|---|---|---|---|
SessionStart | upgrade-nudge.sh | Claude Code | No | Yes (in projects with a .synthex/ directory) |
SessionStart (compact) | compact-recover.sh | Claude Code | No | Yes |
Stop | loop-advance-gate.sh | Claude Code | Yes: keeps a --loop running | Yes |
PreToolUse (Bash / shell) | commit-lint.sh | Claude Code, Codex | Yes: rejects a bad commit | No: needs git.commit_convention: conventional |
PreToolUse (AskUserQuestion) | auto-decide-gate.sh | Claude Code | No: answers questions for you | No: armed by --auto-decide |
TaskCompleted | task-completed-gate.sh | Claude Code | Yes: holds a completed task | No: needs standing_pools.enabled: true |
TeammateIdle | teammate-idle-gate.sh | Claude Code | Yes: keeps a teammate working | No: needs standing_pools.enabled: true |
Claude Code reads the registrations from plugins/synthex/hooks/hooks.json. Codex reads
plugins/synthex/hooks/codex-hooks.json, which the Codex manifest points to and which registers
only the commit-lint hook. The Grok manifest registers no hooks, and Gemini CLI, OpenCode, and
Hermes install the portable skills without any hooks. On those hosts none of these scripts run.
SessionStart: upgrade nudge
- Event:
SessionStart, no matcher (every session start). - Script:
upgrade-nudge.sh.
The script runs only in a project that already has a .synthex/ directory. It never reads
stdin, never prompts, and always exits 0. It compares the plugin version in
.claude-plugin/plugin.json with last_seen_version in .synthex/state.json:
- Same version: it exits silently.
- No state file, or no readable version in it: it writes a fresh state file and prints nothing.
- Downgrade: it updates the state file and prints nothing.
- Upgrade: it can print up to two one-line messages, then records the new version, so each
message appears at most once per upgrade:
- Feature nudge, only when the upgrade crosses version 0.5.0 (where multi-model review
arrived), the project config has no
multi_model_review:block, and you haven't dismissed it. It points you at configure-multi-model/synthex:configure-multi-model. - Star nudge, on any upgrade, unless you have already starred the repo or declined. It points you at star/synthex:star.
- Feature nudge, only when the upgrade crosses version 0.5.0 (where multi-model review
arrived), the project config has no
A third message is not tied to upgrades. While the project still has a legacy .synthex-plus/
directory, the script prints a Synthex Plus deprecation notice on every session start. It stops
once that directory is gone.
What it writes: .synthex/state.json (per developer, gitignored) with schema_version,
last_seen_version, plugin_root, dismissed, starred, star_dismissed, and updated_at.
plugin_root records the installed plugin directory so commands on hosts that don't expand
${CLAUDE_PLUGIN_ROOT} can find Synthex's files.
Turning it off: dismiss-upgrade-nudge/synthex:dismiss-upgrade-nudge sets dismissed: true, which
silences the feature nudge. star/synthex:star sets starred: true or
star_dismissed: true, which silences the star nudge. The deprecation notice clears only when
you remove .synthex-plus/.
SessionStart (compact): loop recovery
- Event:
SessionStartwith matchercompact(right after Claude Code compacts the context). - Script:
compact-recover.sh.
Context compaction can make the model lose track of a running loop. This hook reads every state
file in .synthex/loops/ and, for each loop whose status is running, prints one line with the
loop id, the current iteration and maximum, the state file path, and the command to continue it:
Synthex loop <id> is running (iteration <n>/<max>); state: .synthex/loops/<id>.json — continue with loop-step.sh advance <id>If that loop is waiting on a loop engine verdict (its state
file holds a runId), a second line tells the session to wait for the verdict's notification and
not advance yet.
It prints nothing when no loop is running or the project has no .synthex/loops/ directory. It
reads loop state with node when available and falls back to awk; it has no jq dependency.
It writes nothing and always exits 0.
Turning it off: there is no config key. It is silent unless a loop is running.
Stop: loop advance gate
- Event:
Stop(each time the model tries to end its turn). - Script:
loop-advance-gate.sh.
This hook drives every --loop command and loop/synthex:loop. A Synthex loop does one
iteration per turn. When the model ends a turn while one of this session's loops is still
running, the gate blocks the stop and tells the model to run the next iteration. This is what
keeps a loop going instead of stalling on "want me to keep going?".
The gate stays out of the way (exits 0 with no output) when:
- the input is empty,
jqis not installed, or the session id is missing; - the project has no
.synthex/loops/directory, or no running loop belongs to this session; - a loop engine verdict run is in flight (a
runIdwritten less than 900 seconds ago); a stalerunIdis cleared and the gate carries on; - the transcript can't be read, or the last message is empty.
It lets the turn end when:
- the last turn asked you a question with
AskUserQuestion, so[H]approvals and other required input always reach you; - the last message contains the loop's completion promise (
<promise>…</promise>); - the no-progress cap is reached.
What it writes: on each block it updates the loop's state file with
consecutive_stop_blocks, last_gate_iteration, and last_updated. The counter resets to 1
whenever the loop's iteration number has moved since the last block. If it climbs past the cap
(default 7), the gate lets the turn end and leaves the loop running so you can resume or cancel
it. The cap stays below Claude Code's own limit of 8 consecutive Stop-hook blocks.
When a loop has nothing to do, the command waits inside the turn with loop-idle-wait.sh rather
than ending turns repeatedly, so you don't see a burst of Stop-hook messages. See
Native looping.
Turning it off: there is no config key; the gate acts only on running loops. To stop a loop,
let it emit its completion promise or run cancel-loop/synthex:cancel-loop. Two environment
variables tune it: SYNTHEX_LOOP_BLOCK_CAP (default 7) and SYNTHEX_LOOP_RUN_STALE (seconds,
default 900).
PreToolUse (Bash): commit lint
- Event:
PreToolUsewith matcherBashon Claude Code andshellon Codex. - Script:
commit-lint.sh(the same script on both hosts).
This hook checks the subject line of a git commit against
Conventional Commits. It runs on every shell call but
skips anything that isn't plausibly a commit before doing any real work.
It lints only when all of these hold:
nodeis installed;git.commit_conventionresolves to exactlyconventional;- the command contains exactly one
git commit(alone, in an&&/||/;chain, or at the end of a pipe; a leadingrtkprefix is ignored); - the commit isn't
--amend --no-edit,-C,-c,--fixup,--squash, or part of an in-progress merge; - the subject can be read straight from the command: a literal
-m/--message=value, or-F -fed by a heredoc or a quotedecho/printfin the same command.
Anything else passes unchecked, including -F <file>, -m "$MSG", and messages built with
command substitution.
What it blocks: a subject that doesn't match
<type>[(scope)][!]: <description>, where type is one of feat, fix, refactor, perf,
build, ci, chore, docs, style, test, or revert. The hook exits 2 and prints the
rejected subject and the expected format. The model sees that message and retries with a fixed
subject.
Config:
# .synthex/config.yaml
git:
commit_convention: auto # conventional | issue-key | gitmoji | plain | autoThe shipped default is auto, which never lints. init/synthex:init samples your
git log and writes the convention it detects; only conventional turns linting on. See
Commit convention.
Turning it off: set git.commit_convention to anything other than conventional.
PreToolUse (AskUserQuestion): auto-decide gate
- Event:
PreToolUsewith matcherAskUserQuestion. - Script:
auto-decide-gate.sh.
This hook supports next-priority/synthex:next-priority --auto-decide, for unattended runs.
It does nothing in any session that didn't arm it.
What arms it: with --auto-decide, the command runs auto-decide-gate.sh on at the start,
which creates .synthex/auto-decide/<session_id>.active. When the command finishes (or the loop
ends), it runs auto-decide-gate.sh off, which removes that marker. Arming needs
$CLAUDE_CODE_SESSION_ID; if it's empty, arming fails and the command carries on, with the agent
taking recommended options itself instead of asking.
In an armed session, the hook answers an AskUserQuestion call for you only when:
- every question has exactly one option whose label contains
(Recommended), and - no question's text or header contains
[H].
Human-validated [H] acceptance criteria always reach you. Any other question (no
recommendation, two recommendations, no node, unreadable input) is asked as usual.
What it writes: for each answered question, one JSON line in
.synthex/auto-decide/<session_id>.jsonl with the time, the HEAD commit before the decision,
the question, the choice, and the alternatives. It answers through the hook response, so the
dialog never opens, and tells the model to add an Auto-Decision: <question> => <choice> trailer
to its next commit and list the decision at the top of the pull request. To find decisions later:
git log --grep '^Auto-Decision:' --format='%h %(trailers:key=Auto-Decision,valueonly)'Turning it off: don't pass --auto-decide. The log file stays after the hook is disarmed.
TaskCompleted: review gate
- Event:
TaskCompleted(a teammate marks a task completed in a Claude Code agent team). - Script:
task-completed-gate.sh.
This hook holds a completed task until the right reviewers have seen it. It classifies the files the task touched and names the reviewers for that kind of work:
| Work type | Reviewers named |
|---|---|
| Infrastructure | code-reviewer, terraform-plan-reviewer |
| Frontend | code-reviewer, security-reviewer, design-system-agent |
| Code | code-reviewer, security-reviewer |
| Test | code-reviewer |
| Documentation | none: always allowed |
A task with no files listed counts as code. The table is built into the script. The reviewers
list under hooks.task_completed.review_gate in defaults.yaml is for reading only; changing it
has no effect.
A command hook can't run the reviewers itself, so the block message is the mechanism. It exits 2
and tells the agent which reviewers to use and what to write. Once the reviewers have looked at
the work, the agent writes Review verdict: PASS or Review verdict: WARN in the completion note
and marks the task complete again, which goes through. Review verdict: FAIL keeps blocking.
So a teammate that never learns this convention isn't stuck forever, the second completion attempt
for the same task id is allowed even without a verdict. The hook records the first block in
.synthex/tmp/task-gate/<task_id>; if it can't write there, it allows the completion. A task with
no id can only be released by the verdict line.
Config:
# .synthex/config.yaml
standing_pools:
enabled: false # must be true for this hook to act
hooks:
task_completed:
review_gate:
enabled: trueThe hook does nothing unless the project has a .synthex/config.yaml (or a legacy
.synthex-plus/config.yaml), standing_pools.enabled is true, review_gate.enabled is true,
and node is installed. A user-level config alone never makes it fire in an unrelated repo.
Turning it off: leave standing_pools.enabled off, or set
hooks.task_completed.review_gate.enabled: false.
TeammateIdle: work assignment
- Event:
TeammateIdle(a teammate in a Claude Code agent team has no active task). - Script:
teammate-idle-gate.sh.
This hook keeps an idle teammate busy when there is matching work. From the pending tasks in the
hook input, it picks the lowest-numbered unblocked task whose role matches the teammate's role. If
none match and allow_cross_functional is true, it takes any unblocked task. If it finds one, it
exits 2 and names the task; the calling agent does the actual assignment.
Standing review pool members are exempt: the hook always lets them go idle, because their Pool Lead manages them.
If the same task would be suggested twice in a row (for example, the assignment failed), the hook
lets the teammate go idle the second time instead of looping. It records suggestions in
.synthex/tmp/idle-gate/<task_id> and allows idle if it can't write there.
Config:
# .synthex/config.yaml
standing_pools:
enabled: false # must be true for this hook to act
hooks:
teammate_idle:
work_assignment:
enabled: true
allow_cross_functional: falseThe same gating applies as the review gate: a project-level config file, standing_pools.enabled: true, the hook's own enabled: true, and node.
Turning it off: leave standing_pools.enabled off, or set
hooks.teammate_idle.work_assignment.enabled: false.
Next
- Native looping — the loops the Stop and compact hooks drive
- Standing pools — the agent teams the TaskCompleted and TeammateIdle hooks serve
- Configuration — every config key, with defaults
- Plugin architecture — where hooks sit in the plugin