REFERENCE · HOOKS

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

EventScriptHostCan block?On by default?
SessionStartupgrade-nudge.shClaude CodeNoYes (in projects with a .synthex/ directory)
SessionStart (compact)compact-recover.shClaude CodeNoYes
Stoploop-advance-gate.shClaude CodeYes: keeps a --loop runningYes
PreToolUse (Bash / shell)commit-lint.shClaude Code, CodexYes: rejects a bad commitNo: needs git.commit_convention: conventional
PreToolUse (AskUserQuestion)auto-decide-gate.shClaude CodeNo: answers questions for youNo: armed by --auto-decide
TaskCompletedtask-completed-gate.shClaude CodeYes: holds a completed taskNo: needs standing_pools.enabled: true
TeammateIdleteammate-idle-gate.shClaude CodeYes: keeps a teammate workingNo: 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.

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: SessionStart with matcher compact (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, jq is 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 runId written less than 900 seconds ago); a stale runId is 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: PreToolUse with matcher Bash on Claude Code and shell on 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:

  • node is installed;
  • git.commit_convention resolves to exactly conventional;
  • the command contains exactly one git commit (alone, in an && / || / ; chain, or at the end of a pipe; a leading rtk prefix 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 quoted echo / printf in 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 | auto

The 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: PreToolUse with matcher AskUserQuestion.
  • 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 typeReviewers named
Infrastructurecode-reviewer, terraform-plan-reviewer
Frontendcode-reviewer, security-reviewer, design-system-agent
Codecode-reviewer, security-reviewer
Testcode-reviewer
Documentationnone: 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: true

The 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: false

The 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