Agent setup and hooks
jouet setup connects jouet to a coding agent's harness by installing hooks that call back into jouet hook. When a session starts, the hooks add a few lines about jouet's commands. Before a shell command or file tool runs, they apply jouet's rules, which deny a handful of costly hand-rolled patterns and name the jouet command or git habit to use instead. When the agent tries to finish, they block it once if a test, lint or build run failed, or if it changed files and no test run has passed since.
With --init-skill, setup also installs the jouet-init skill, which an agent uses to configure jouet for a project: after setup, start a new agent session in the project and run /jouet-init in Claude Code, $jouet-init in Codex, or ask the agent to use jouet-init. It is not installed by default: it ran in no real session over 14 days on the machine that measured it, and on the bench it gained nothing over jouet's defaults.
Early development
The hooks, their rules and the settings below are new and may change between releases.
Installing
Name the harness. Without a scope flag, setup installs for every project, under your home directory (--global):
jouet setup claude # Claude Code: ~/.claude/settings.json
jouet setup codex # Codex: ~/.codex/hooks.json
jouet setup cursor # Cursor: ~/.cursor/hooks.json
jouet setup gemini # Gemini CLI: ~/.gemini/settings.json
jouet setup copilot # Copilot CLI: ~/.copilot/hooks/jouet.json
jouet setup claude --skill # also ~/.claude/skills/jouet/SKILL.md
jouet setup claude --init-skill # also ~/.claude/skills/jouet-init/SKILL.md
jouet setup claude --project # this repository, for you alone (.claude/settings.local.json)
jouet setup claude --shared # this repository, for everyone (.claude/settings.json)
jouet setup codex --dry-run # show the diff, write nothing
jouet setup codex --uninstall # remove what jouet addedSetup prints a line for each file it created, updated, removed, linked, kept or left unchanged, then a note: for anything left for you to do:
$ jouet setup codex
created /home/me/.codex/hooks.json
note: Codex skips new or changed hooks until they are trusted: run /hooks in Codex and trust jouet's.Codex, Cursor, Gemini CLI and Copilot each have a step of their own before the hooks run. In Codex, run /hooks and trust jouet's; upgrading from a version that installed bare commands changes them, so they need trusting again. Cursor runs a project's hooks only in a trusted workspace. Gemini CLI runs hooks from settings files (yours too) only in a trusted folder. Copilot reads a repository's hooks only once you trust the folder, and reads hooks only when it starts, so restart Copilot CLI after setup.
The hooks find jouet on PATH and do nothing where it is missing; setup says so when it is not on PATH where you run it (see when jouet is not on PATH). setup does not install the usage skill unless you pass --skill, or the hooks or the primer are turned off in the settings: the primer already tells the agent what that skill would, in every session. In a fnox project, setup also recommends starting the agent under fnox's credential proxy.
--dry-run prints each file's diff and writes nothing. With --json, the report is one document: harness, scope (global or project), shared, dry_run and uninstall when set, files (each with its path, an action of create, update, remove, link, unchanged or keep, and a diff when it changes), notes, benchmark_prompt with --benchmark, and context_budget when setup has a context budget to offer.
What setup changes
The table shows the optional usage skill. With --init-skill, the onboarding skill is installed beside it at skills/jouet-init/SKILL.md, with the same scope and harness-specific home directory.
| Harness | Scope | Hooks | Optional usage skill |
|---|---|---|---|
| Claude Code | --global (default) | ~/.claude/settings.json ($CLAUDE_CONFIG_DIR if set) | ~/.claude/skills/jouet/SKILL.md ($CLAUDE_CONFIG_DIR/skills if set) |
| Claude Code | --project | .claude/settings.local.json | .claude/skills/jouet/SKILL.md |
| Claude Code | --shared | .claude/settings.json | .agents/skills/jouet/SKILL.md, linked from .claude/skills/jouet |
| Codex | --global (default) | ~/.codex/hooks.json ($CODEX_HOME if set) | ~/.agents/skills/jouet/SKILL.md |
| Codex | --shared | .codex/hooks.json | .agents/skills/jouet/SKILL.md |
| Cursor | --global (default) | ~/.cursor/hooks.json | ~/.agents/skills/jouet/SKILL.md |
| Cursor | --shared | .cursor/hooks.json | .agents/skills/jouet/SKILL.md |
| Gemini CLI | --global (default) | ~/.gemini/settings.json | ~/.agents/skills/jouet/SKILL.md |
| Gemini CLI | --shared | .gemini/settings.json | .agents/skills/jouet/SKILL.md |
| Copilot | --global (default) | ~/.copilot/hooks/jouet.json ($COPILOT_HOME/hooks if set) | ~/.agents/skills/jouet/SKILL.md |
| Copilot | --shared | .github/hooks/jouet.json | .agents/skills/jouet/SKILL.md |
--projectis yours alone. It writes at the root of the enclosing git repository (the current directory outside one), into Claude Code's.claude/settings.local.json, which Claude Code reads alongsidesettings.jsonbut which is not meant to be committed. From a linked worktree it writes the main checkout's, except on Windows (see worktrees). Claude Code ignores that file in git once it has written to it itself; until then, and for the skill, setup says when git does not ignore them, so you can list them in.gitignoreor.git/info/exclude.--sharedis for everyone who clones the repository. It implies--projectand writes the repository's tracked hooks file (the--sharedrows above), for a team that has agreed to use jouet. Since the hooks do nothing where jouet is not installed, contributors without it are not broken, but they get no hooks either. Every harness reads the same skill text, so the repository keeps one copy of each skill, in.agents/skills, and Claude Code's.claude/skills/NAMEis a relative link to it. Where.claude/skillsis already a link to.agents/skills, setup adds none; where links cannot be made, as on Windows without the right to make them, it writes Claude Code's copy as a file. Uninstalling Claude Code, or setup no longer installing a skill, removes the link, and the shared copy too unless another harness here still has jouet's hooks; uninstalling another harness keeps the copy while Claude Code's link names it.--dry-runshows both:would create .agents/skills/NAME/SKILL.mdandwould link .claude/skills/NAME.- Only Claude Code has a project hooks file of your own. Codex reads project hooks only from the repository's
.codex/hooks.jsonand.codex/config.toml, Cursor from.cursor/hooks.json, Gemini CLI from.gemini/settings.jsonand Copilot from.github/hooks/*.json, so--projectis refused for them (exit 2): use--global, or--sharedto install there for everyone.--globaltogether with--projector--sharedis refused as well. - Merges, never clobbers. jouet adds its own matcher group (in Cursor's and Copilot's files, its own handler) to each event and leaves every other hook, setting and key order as it was. A file that is not valid JSON, or whose
hooksis not an object of arrays, is refused (exit 4) and left alone. A file that is a symlink, as dotfile managers make them, is written through to the file it names, keeping that file's permissions. - Re-running is a no-op. jouet recognises its entries by their command, which runs
jouet hook ..., and only rewrites them when they differ from what this version installs. Entries an older version installed, such as the barejouet hook claude stop, are replaced in place, not duplicated. Updating jouet does not touch the settings file, so a hook event that a newer version added is missing from it until you runjouet setup claudeagain, with the scope flag you installed with if it was--projector--shared(jouet setup codexand the other harnesses work the same way). Adding--dry-runshows what a rerun would change. - hk's stop hook is merged in. When the file already has hk's stop hook (
hk agent stop-hook, ashk agent hooksinstalls it), setup folds it into jouet's stop handler, which runs it, so a stop gets one verdict. setup notes when another of the harness's hook files has hk's stop hook, which would still block on its own; see one stop gate. - Claude Code: the Artifact tools are denied. setup adds
Artifact,ArtifactCommentsandArtifactDatatopermissions.denyin the same file, which removes their definitions from every request: about 12,800 input tokens per request, measured with Claude Code 2.1.309, which none of 524 sessions over 14 days on the machine that measured it called. A tool the file already names inpermissions.alloworpermissions.askis left out, so to keep one, add it topermissions.allow; removing the entry alone makes the next setup add it back. Other tools, such as Agent, Skill and Monitor, are left as they are. - Each handler has a 10-second timeout, the bound on a hung hook, after which the harness carries on without it; jouet's hooks take milliseconds. A stop handler with hk's merged in gets hk's timeout instead, or 600 seconds when hk's had none.
--uninstallremoves only jouet's entries, and in Claude Code's file the three Artifact denials and whatever the context budget added, then any group, event orpermissionstable that removing them left empty, and puts a merged hk stop hook back as it was. At project scope it removes them from bothsettings.local.jsonandsettings.json, whichever setup wrote. A settings file left empty stays, as{}; Copilot'sjouet.json, which is jouet's alone, is removed.- A skill is jouet's only while it is unedited. setup ends each
SKILL.mdit writes with a line holding a hash of the rest. It replaces or removes that file only while the hash still matches, and keeps skills you wrote or edited. Running setup without--skillremoves the usage skill an earlier version installed, and without--init-skillthe jouet-init skill, each while it is unedited.--uninstallremoves both while unedited.
Context budget
For Claude Code at --global or --project scope, setup reads your own recent sessions (the newest 50 transcripts under ~/.claude/projects that record their tool definitions) and offers two changes to the same settings file. It writes neither unless you accept:
A compaction window. When one of those sessions went past 200k tokens of context, which only a 1M-token model can, setup offers
autoCompactWindow: 200000, so Claude Code compacts the conversation at 200k instead of near 1M. Replaying one machine's 14 days of sessions with compaction at 200k cut input by 45–49% in tokens, or 38–40% weighted by price. That is an upper bound: it assumes the same requests after a compaction, and agents re-read some of what a compaction dropped. Sessions on a 200k model already compact there, so they are offered nothing. Nothing is offered when the settings orCLAUDE_CODE_AUTO_COMPACT_WINDOWalready choose a window, top-level or undermodelSettings.Denying what you added and never use. An MCP server or a plugin's skill that was in at least 20 of those sessions and called in none is offered, with its estimated tokens per request (from the size of its definition, so ±30%). MCP servers go into
permissions.deny(mcp__linear); skills getskillOverrides: {"NAME": "user-invocable-only"}, which takes them out of the model's skill list but keeps/NAMEfor you. With fewer than 20 sessions, nothing is.Only what you added can be picked: an MCP server in
.claude.jsonor in the.mcp.jsonof any project Claude Code has opened, a plugin's server (plugin_*), a claude.ai connector, or a plugin's skill (plugin:skill). Everything else is depended on, called or not. Claude Code's and the desktop app's built-in tools (ReadNotifications,ReportFindings,ScheduleWakeup) answer a notification, a review or a wake that a quiet stretch of sessions never shows. The app's own servers (ccd_*,terminal,visualize) are how it reaches the session. A skill without a plugin prefix is yours (~/.claude/skills), a project's (.claude/skills) or one Claude Code bundles (loop), and it is there to trigger on its own. You can still deny any of them yourself. A run that reads the history also puts back what an earlier version of setup picked that this rule keeps, and says which.
At a terminal, setup asks about each with [y/N]. Anywhere else, including an agent running setup, it only adds a note naming what the flags would do, and --json has the same as context_budget. To write them without asking:
jouet setup claude --compact-window 200k # 100k to 1m, which is what Claude Code accepts
jouet setup claude --deny-unusedA window already in the settings that setup did not set is left alone. Each choice is one undo, which removes only what setup added, recorded in setup-budget.json in jouet's state directory: jouet setup claude --undo window or --undo unused (with --project for that scope). --uninstall takes back both. A denied tool fails silently: the agent never learns it exists, so undo a denial when you want the tool back.
What the hooks do
| Event | Claude Code | Codex | What jouet does |
|---|---|---|---|
| Session start | SessionStart | SessionStart | Adds the primer: the command families by name, usage for the common commands, the rules that deny and what each suggests instead, and notes such as the slow checks here, how to use a fnox project's secrets, or that this repository has rules and routines that do not run until the user trusts them. |
| Subagent start | SubagentStart | none | Gives a subagent (Claude Code's Agent tool) the same primer, without the winding notes, since a subagent sees nothing of its session's context. |
| A turn starts | UserPromptSubmit | none | Records in the ledger what started the turn: a prompt the user typed, or one of the desktop app's own events (an Auto-fix CI failure, merge conflict or review comment, side sessions finishing, a message from another session, a background task's notification), with how many PRs, comments and failing checks an Auto-fix event names. It passes every prompt on unchanged and blocks nothing. |
| Before a shell command, a Read, a Grep or a file edit | PreToolUse (Bash|Read|Grep|Edit|Write|MultiEdit|NotebookEdit) | PreToolUse (^Bash$) | Runs the pre-tool-use rules: denies the command with one line of advice, or passes on a warning. |
| After a shell command or a Read | PostToolUse (Bash|Read), PostToolUseFailure (Bash) | none | Runs the post-tool-use rules, which record test, lint and build runs and can warn about how a command ended, keeps how long each check took for jouet cost, and remembers which lines of which files the session read. |
| After a file edit | PostToolUse (Edit|Write|MultiEdit) | PostToolUse (^apply_patch$) | With [edit] fix_after, formats the file with the project's hk fixers (how); Codex patches are not formatted. Then, where the work tree has a code graph, re-indexes the edited files ([hooks] graph). |
| Stop | Stop | Stop | Finds a reason to hold up finishing: a recorded test, lint or build run that failed, files changed after the last passing test run with no test run passing since, or files changed after the last hk check passed. By default it only records that it would have blocked; with [hooks] gate = "block" it blocks once. With hk's stop hook merged in, hk's verdict blocks either way. |
Cursor, Gemini CLI and Copilot get the same hooks under their own event names; Cursor, Gemini CLI and Copilot lists them, and what each harness cannot do.
Every hook reads the harness's JSON on stdin and fails open: input it cannot read, a broken config file, a bad JOUET_DISPLAY, a rule that throws or runs out of its budget, or a crash all exit 0 with no decision, so a problem in jouet never blocks the agent. Rule errors are reported on stderr. A pre-tool-use hook takes about 2 ms in a release build, starting the process included.
When jouet's hooks are installed twice with different commands, such as at user scope and in a project's committed settings written by an older jouet setup, the harness runs both. jouet then answers a session start or a tool call (by its tool_use_id) only once. The second run within 5 seconds prints nothing, so the agent does not read the primer or a warning twice. A stop has no such id, so it is still answered twice.
The session-start primer
The primer is four lines, about 490 tokens, and every request in a session pays for it. In the jouet repository, Claude Code's reads as follows; the last line is one of the notes described below:
jouet is installed, a CLI toolkit for coding agents: claim, commit/secrets, diag, edit, handoff/pickup, pr, read/docs/deps/db, review-package, stack, search, test, wait/http, job, ci, rule, run, wind.
Test with
jouet test --affected(what your changes affect) orjouet test --cmd -- CMD...(any other test or build). Give a build or test that can take over 2 minutes a Bashtimeoutof 600000, so it ends in that call. Commit only the paths you name withjouet commit <PATHS>... --message MSG, in the same call as their test run (jouet test ... && jouet commit ...). Put a step's edits in onejouet edit replace <PATHS>... --old TEXT --new TEXT(several files, repeated --old/--new pairs), chained with its test; never a Python script. Find code withjouet search PATTERN [PATHS](matches grouped by function, within a budget) instead ofgrep -rn, and read definitions withjouet read PATH --symbol NAME --range A:B(each repeats) instead ofsed -nranges. Each call costs the whole context, so put independent reads and searches in one. List a PR's open review threads withjouet pr threads [PR]; reply to and resolve one withjouet pr threads reply <ID> [PR] --body TEXT --resolve. These need no lookup; for other commands,jouet agent-help COMMANDgives their usage.jouet's hooks deny these and say what to do instead: sleep-poll (use
jouet wait ci), ci-rerun (usejouet ci explain), review-threads (usejouet pr threads), git-add-all (usegit add path/to/file ...), git-stash, no-verify, force-push (usegit push --force-with-lease), print-secrets, secret-files (usefnox exec -- <command>), job-start, job-ready (usejouet job start ID --http URL -- CMD), trust.They also stop you finishing while a test, lint or build run failed, or when no test passed since your last change. For an unrelated failure, compare its command with
jouet test --baseline --cmd -- CMD.jouet: slow checks here:
mise run test~3m44s,mise run test:bats -- test/stats.bats~3m33s,cargo build -q --release~2m30s,bats test/hook.bats test/stack.bats~1m14s (jouet costlists all). Finish the edits a slow run checks before starting it, so one run covers them.
The primer names the command families but leaves out what each is for, which is one jouet agent-help COMMAND away. Its other parts, and why they are there:
- Common commands. Usage for
jouet test,jouet commit,jouet edit replace,jouet search,jouet read --symbol, andjouet pr threadswith itsreply, with a note that these need no lookup.jouet test --affectedis named only where it can select tests. - Commit with the tests.
jouet commitgoes in the same call as the test run, which saves agents a request of their own for it. - Search and read by symbol.
jouet searchandjouet read --symbolare given with their usage, in place ofgrep -rnandsed -nranges: agents never used them while the primer only named them, and spent most of an exploration-heavy task on greps. It also says to read several definitions, and run independent reads and searches, in one call, since every call carries the whole context. - No Python rewrites, only while
python-editis on ([rules] enable = ["python-edit"]; it is off by default). The primer then says to use ajouet editspec, never Python, because agents reached for a heredoc to batch replacements even with the rule named. With the rule off, nothing denies Python, so the primer does not forbid it. - The rules that deny, with the command each suggests, which spares the agent a denied call to learn them. Only rules that can fire here are named: the pitchfork rules only in a project with a pitchfork config.
- The stop check, while
[hooks] verifyis on and[hooks] gateisblock, and how to compare an unrelated failure with the merge base. While the gate only observes, the primer leaves the line out, since nothing stops the agent. - Long runs in one call. How the harness waits for a long build or test without a call per check. Claude Code: a Bash
timeoutof 600000, past which a call moves to the background. Codex: a largeyield_time_msor a loop onwrite_stdin, not a call per poll. Cursor, Gemini CLI and Copilot get no such line. In Claude Code the pre-tool-use hook also sets that timeout itself on a test, build or lint call that has none and is not run in the background, throughupdatedInput. It sends no permission decision, so Claude Code still applies your own permission rules and prompt to the call, in every permission mode. Setting the timeout never changes the command itself; the one rule that does ispiped-test-output, below.
A note follows the primer only when it applies:
- where
jouet costhas timed checks of 20 seconds or more, the slowest, up to four, so the agent can finish the edits a slow run checks before starting it; - at most two notes from self-winding, such as a rule drafted from the ledger;
- when this repository has rules and routines that do not run until the user trusts them, how many, and that trusting them is the user's decision, in their own terminal; when a plugin is not fetched as pinned, that
jouet plugin syncfetches it; - in a fnox project, how to use its secrets, or that the session runs under
fnox proxy run; - for each deprecated whole-file
.luaurule, a line the agent can act on to convert it.
Steering rules
The rules are TOML. They come from five places: built in; your organisation's in /etc/jouet/rules, which your settings cannot turn off; yours in ~/.config/jouet/rules; the plugins you pin by commit; and a repository's .jouet/rules once you trust it with jouet trust. Yours only warn unless they say mode = "deny", and no rule of yours can replace or overrule a built-in one. Rules from outside run only once they pass their examples. Rules covers writing, testing and disabling them; jouet rule list shows which run.
A rule only advises. jouet denies the command or warns about it and tells the agent what to do instead. The one exception is piped-test-output on Claude Code, which runs a cut test or build run under jouet test --cmd instead. The agent sees the advice and the rule's name:
Run `git add path/to/file ...` instead. `git add -A` and `git add .` sweep in caches, scratch files and other sessions' work. [jouet rule: git-add-all]The built-in rules:
| Rule | Denies | Suggests |
|---|---|---|
sleep-poll | sleep N together with gh pr checks, gh run view or a statusCheckRollup query | jouet wait ci |
ci-rerun | gh run rerun (bare, --failed or --job), and gh api calls to a run's or job's rerun endpoints | jouet ci explain, whose --rerun reruns the failed jobs |
ci-pending | warns (does not deny), after the call, when gh pr checks exited 8: checks are still pending | jouet wait ci rather than checking again |
ci-failed | warns (does not deny), after the call, when gh pr checks exited 1, or gh run view or gh run watch with --exit-status did: a check failed, or gh could not answer | jouet ci explain before reading logs or pushing a fix |
review-threads | gh api graphql (or curl to the GraphQL API) with a reviewThreads query | jouet pr threads |
python-edit | Off by default; [rules] enable = ["python-edit"] turns it on. A python heredoc or -c script that reads a file and writes it back, by .replace(, slicing, a regex or anything else (a file written from another, a json, yaml or toml rewrite, or a script that stops on a miss before it writes, with an assert, raise or sys.exit, or by finding its place with .index(, which raises on a miss, and no .replace( or re.sub, is left alone). A script whose .replace( calls each take a plain literal is checked against the file instead: it runs when every old text is there, and is denied, naming the text, when one is not | jouet edit |
sed-line-edit | warns (does not deny) on sed -i that deletes, changes or inserts by line number (356,413d, 12i\): line numbers shift once one edit lands. Substitutions and pattern addresses are left alone | your edit tool, or jouet edit replace |
stdin-hang | in Claude Code, which leaves a command's standard input open with nothing on it, cat with no file (cat > FILE) or Python with no script (python3 -), when no here-document, <, <<< or pipe feeds it: the read would wait until the call times out, 10 minutes later | a here-document, a pipe or a file, or dropping the command |
git-add-all | git add -A, --all, . or :/ | staging explicit paths |
git-stash | a bare git stash, push or save without a message, pop, apply or drop without a stash named, and clear, in a repository with linked worktrees, judged where a cd before it leads (or anywhere, after a cd jouet cannot follow); with one worktree, nothing else shares the stash | git stash push -m <tag> and git stash apply <sha>, or a WIP commit, because every worktree of a repository shares one stash |
no-verify | --no-verify on any git command, and git commit -n | fixing what the hook reports |
force-push | git push --force, -f or a +refspec without --force-with-lease | --force-with-lease |
reset-to-remote | warns (does not deny) on git checkout -B X START, git switch -C X START, git branch -f X START and git reset --hard origin/… when the local branch has commits START lacks, such as fixes not yet pushed | git merge START on the branch, or jouet stack sync |
rebase-merged-base | warns (does not deny) on git rebase, gh stack rebase and gh stack sync in a stacked branch whose prerequisite has merged: from gh-stack's metadata (the prerequisite's PR was submitted and its remote branch is gone), or with rules.network, from GitHub | jouet stack sync, which merges the trunk in |
print-secrets | in a fnox project: bare env, printenv, export -p and set, printenv NAME and echo $NAME for a secret its fnox.toml defines; anywhere: fnox get, fnox export without -o, fnox list -V and fnox env --json, unless redirected, piped or captured | fnox exec -- <command> |
secret-files | in a fnox project: reading .env or .env.* (not .env.example, .sample, .template or .dist), and the files fnox's plain providers read, with Read, cat, grep, sed -n and similar; in any project: reading a credential file in the home directory (~/.npmrc, ~/.pypirc, ~/.netrc, ~/.git-credentials, ~/.docker/config.json, ~/.config/gh/hosts.yml, ~/.cargo/credentials*, ~/.gem/credentials, ~/.aws/credentials, ~/.kube/config) | fnox exec -- <command> |
committed-secrets | git commit when the lines it would add (staged, every tracked change for -a, and the paths a git add in the same call names) hold a secret gitleaks' rules recognise, and git push when commits not on any remote add one; only warns when gitleaks' generic rule is all that matched; skipped for a diff over 1 MiB | taking the secret out; for a false positive, jouet commit --allow-secret RULE:PATH:LINE, gitleaks:allow or .gitleaksignore |
scoped-lint | warns (does not deny) on cargo clippy piped into head or tail, eslint ., and a bare mise run lint, unless it is the full lint: the work tree has nothing uncommitted (a check after a merge, pull or checkout), clippy denies warnings across the workspace or all targets as CI does, or the same call runs the test suite | jouet diag check --changed while iterating, and the full lint once at the end |
affected-tests | warns (does not deny) on a whole-suite pytest, jest, vitest run, npm test or mise run test within 30 minutes of the last whole-suite run (unless that run's output was cut with head, tail or grep), in a repository where jouet test --affected can select | jouet test --affected while iterating, keeping the full run for the end |
piped-test-output | on Claude Code, runs a test, build or e2e run piped straight into head, tail, grep, rg or sed -n under jouet test --cmd instead, with the rest of the call as written (how); otherwise warns the first time in a session, and denies after that, on a test, build or e2e run (cargo test, cargo build, go test, pytest, vitest, jest, bats, npm test, mise run test:e2e, make, ...) whose output is piped into head, tail, grep, rg or sed -n (jouet test itself, which records its own result and prints little, gets one warning for a head or tail on it, never a denial), or written to a file that the same call reads back with one of them; not on lints (scoped-lint's), --help or --list, or output left in a file. Without a session id (or a ledger to count in) it only warns | jouet test --cmd -- CMD, with the command filled in |
unbudgeted-search | warns (does not deny), in a repository of 200 tracked files or more, on rg or grep -r over the whole working directory with no --max-count, no -l or -c, and no pipe into head, tail or wc; and on Claude Code's Grep tool printing matching lines over the whole tree with no head_limit | jouet search PATTERN |
reread | warns (does not deny) on reading lines this session already read, from a file unchanged since: Claude Code's Read tool, or a shell read with cat, head, tail, sed -n or nl | using what it read earlier, or jouet search PATTERN FILE, which shows only matches the session has not seen |
large-read | warns (does not deny) on reading 400 lines or more of source code at once: Claude Code's Read with no offset or limit, or cat or nl | jouet read FILE --outline, then --symbol NAME |
unbounded-query | warns (does not deny) on sqlite3 DB "select …", sqlite3 DB .dump or psql -c "select …" printing to the transcript, with no LIMIT and not a single aggregate row | jouet db query |
curl-json | warns (does not deny) on curl piped into jq, or into Python code that parses JSON (python3 -c 'import json...', python3 -m json.tool, python3 - <<EOF); not on downloads to a file (-o, -O) or installers piped into sh | jouet http GET URL --select '$.path' |
job-start | a dev server or watcher (npm run dev, vite, python -m http.server, uvicorn, rails s, cargo watch, docker compose up and others), or a build or test suite (cargo test, npm run build, pytest, make, ...), started with & or nohup ... &, unless the same call waits for it; warns on a server the harness runs in the background | jouet job start ID -- CMD, with the ID filled in, and for a server a ready check (--http URL the call goes on to poll, or --port N it is told or defaults to) |
job-ready | sleep together with curl, wget, http or nc against localhost, 127.0.0.1, 0.0.0.0 or [::1], as in sleep 5 && curl localhost:3000 or until curl ...; do sleep 1; done | jouet job start ID --http URL -- CMD, or for a server already running jouet wait http URL (jouet wait port PORT for one that does not speak HTTP) |
job-logs | warns on tail -f of a *.log or *.out file, and on reading nohup.out | jouet job logs ID -n 50 |
job-stop | warns on kill $(lsof ...), lsof ... | xargs kill, pkill/killall of a dev server, fuser -k and kill-port | jouet job stop ID |
claimed-path | warns (does not deny) on an edit (Edit, Write, MultiEdit, NotebookEdit), git commit, jouet commit or jouet edit replace that touches a path another live session claims, naming that session, its worktree and branch | leaving the path to that session, or coordinating first |
pitchfork-start | starting by hand a daemon the project's pitchfork config declares: a command that is a daemon's run command (arguments added or not), or a server told to listen on a daemon's port | pitchfork start ID, with the daemon named |
pitchfork-stop | kill $(lsof ...), lsof ... | xargs kill, pkill/killall, fuser -k or kill-port aimed at a declared daemon's port or command line | pitchfork stop ID or pitchfork restart ID |
pitchfork-logs | warns on pitchfork logs --follow | pitchfork logs ID -n 50 |
trust | jouet trust and jouet plugin add, with or without --yes: a repository's rules put text in front of the agent and its routines run commands, so the agent must not be the one to trust them | asking the user to run it in their own terminal |
rule-files | changing, moving or deleting a rule in deny mode in ~/.config/jouet/rules or the system directory, whether with Edit, Write, MultiEdit or NotebookEdit, jouet edit, sed -i, rm, mv, cp, tee, truncate or a > redirect, or moving or deleting the whole directory; a rule in warn mode, a new file, and a rule whose author is the session itself are left alone. Warns (does not deny) on the same changes to a repository's .jouet/rules, which turn off all of its rules until the user trusts them again | jouet rule new NAME --why TEXT for a new rule, or asking the user |
One more built-in rule, checks, neither denies nor warns: it records test, lint and build runs for the stop check.
The pitchfork-start and pitchfork-stop rules run only in a project with a pitchfork config (pitchfork.toml, pitchfork.local.toml, .config/pitchfork.toml or .config/pitchfork.local.toml in the call's directory or one above it), and read the daemons it declares from the files, without running pitchfork. They run before the job-* rules, so a declared daemon goes to pitchfork and anything else to jouet job; elsewhere the session-start primer leaves them out. The stop hook leaves pitchfork daemons alone: they are meant to keep running.
A rule that names a jouet command is only active when the installed jouet has that command, so the hooks never suggest something that is not there.
The rules read the command the way a shell does: quoted strings, here-document bodies and comments are data, so git commit -m "never git add -A" passes, while commands inside $(...) and sh -c '...' are checked. Wrappers such as env, sudo, timeout, rtk and mise x ... -- are looked through.
Finishing on a failing check
The stop hook keeps an agent from declaring success while a test, lint or build run it made failed, or after it changed files with no test run passing since, in the spirit of "verification before completion". A passing partial run satisfies the hook, so the hook alone does not show that the full suite passed.
After a failed cargo test, the agent's next stop is blocked with this reason:
The last test run failed (`cargo test` exited 101). Fix it and run it again before you finish, or say plainly why it cannot pass. If the failures are not from your change, `jouet test --baseline --cmd -- cargo test` runs it at the merge base too, and passes when each failure fails there as well. [jouet: verify]It blocks once: if the agent stops again straight away, its answer stands. The rest of this section says how the hook decides.
Observing or blocking
The gate observes by default. Where it would block, it records a would-block in the ledger, with what the reason is about, and lets the agent finish. jouet stats then shows, for each reason, how many stops it was found at and whether it proved right: whether the session's next edit or failed check came before any check passed. Over 14 days of blocking, an edit followed only 3.4% of blocks; most ended in a rerun that passed, or a reply.
To have it block, set it in the config file or the environment:
[hooks]
gate = "block" # JOUET_HOOKS_GATE=blockA share of sessions keeps blocking regardless, as an experiment arm, unless [experiment] control_share = 0.
Each reason is raised once either way, so a would-block is recorded where a block would have been, and not again at every stop after. hk's own stop hook, when merged in, is hk's verdict, and blocks in both modes.
What counts as a check
- Recorded commands. Whatever the post-tool-use rules record. The built-in
checksrule recordscargo test,build,check,clippyandfmt --check(also throughmbx),mise runtasks namedtest*,lint*,check*,build*orci,npm/pnpm/yarn/buntest and scripts with those names,make/justtargets,go test/vet/build,pytest,python -m unittest,vitest,jest,bats,jouet test(with--cmd, as the command it ran),hk checkand a few common linters; add a script rule of your own withon = "post-tool-use"that callsjouet.recordto count more. - Ruff previews.
ruff check --diffandruff check --fix-onlyare not counted as lint checks: the former reports a proposed diff, and the latter can leave violations unreported. Runruff checkto verify lint. - Full or partial. Each run is recorded with its scope:
fullwhen it covered the whole project, orpartialwith what it selected. For cargo,-p(unless the packages named are every workspace member),--test,--lib,--bin,--exclude, a test-name filter,--exactor--skipafter--, and nextest's-Emake a run partial;--workspace, a plain run in a single-package project, and a plain run at a virtual manifest withoutdefault-membersare full. A plaincargo testat the root of a workspace that is also a package tests only that package, so it is partial. A task runner's task is full when it is run alone by a plain name (mise run test,npm test,make check), and partial with arguments or a sub-task (mise run test:e2e edit).go test ./..., andpytest,vitestorjestwithout paths or name filters, are full, as ispython -m unittestwith no tests named (ordiscoverwithout a pattern or-k).jouet testis full, andjouet test --affectedis partial, selecting--affected(seejouet test).jouet test --cmd -- CMDhas the kind and scope of CMD, read through wrappers such asmise x --,envandtimeoutas any command is, and is not recorded when the rule does not know CMD, as CMD run directly is not; a dry run is not recorded. Anything else, including acdthe rule cannot follow, is partial. - When a failure counts. Only when the check is the command's last pipeline, so its exit status is the command's:
cd crate && cargo testcounts,cargo test; echo donedoes not. A jouet command that exits 2 (usage) or 4 (refused), such asjouet diag checkwhere there is no hk config orjouet testwith no runner it knows, checked nothing: it is not recorded, so it neither fails a check nor clears one. jouet testkeeps its own result. Piped, as injouet test | tail, a run exits with tail's status, so the hooks cannot tell how it went; Codex sends hooks no exit status at all. Sojouet testrecords each run itself, in the session's state (found byCLAUDE_CODE_SESSION_IDorJOUET_SESSION_ID) and in the ledger. A dry run records nothing.
What clears a failure
- A later pass. A later full pass of the same kind, or a later pass of a partial run that selected the same thing (
cargo test -p afailing, thencargo test -p apassing). A bats run of a directory covers each.batsfile in it (in its subdirectories too with-r), unless it picked tests with-f,--filter-tagsor--filter-status:bats -j 8 test/passing clears a failedbats test/rule.bats. A partial pass never clears a full failure:cargo test -p apassing aftercargo test --workspacefailed still blocks, and says only a full run clears it. - Failures that were there before.
jouet testcompares a failing full suite with the merge base on its own, and passes when every failure fails there as well; what the merge base did is kept, so later runs compare without running it again (see--baseline). When a full test run or a build fails, the hook's message suggests that run's own command with--baseline, through--cmdwhen it did not go throughjouet test. When the merge base could not run the command either, the hook asks about its failures once, and not again until the command passes, whatever changes. - Failures the machine caused. A failed run whose every failure blames the machine rather than the change does not hold up finishing: no D-Bus or keyring (
DBus error, the Secret Service), a system library missing (a shared object that will not load, a pkg-config miss,could not find system library), no network (could not resolve host,network is unreachable), or an API's rate limit. The stop says each such failure once, in a note for you (systemMessage), with the line that blamed the machine. A missing command or file is not counted, since it is most often the change's own, such as a binary it did not build, and one failure that says nothing of the kind among them makes the whole run count. jouet reads a failed run's output fromjouet test's own log, or from Claude Code'sPostToolUseFailurepayload. - Failures in tests the change does not reach. A failed
jouet testrun (the full suite or a--cmd) whose every failing test is one the change cannot have broken does not hold up finishing either, and is not run again at the merge base. jouet looks each failing test up in its code graph. It counts only when every test is defined in a test file whose package the change does not reach:jouet test --affectedselects nothing in that directory, and no changed file is in it. The run says so on anunrelated:line, and the stop says it once, in a note for you. Any doubt makes the failure count as it always has: a failure that names no test (a build error, a lint finding), a test the graph does not know, a change that makes--affectedrun the whole suite, or a test owned by an adapter that selects by package rather than by file, such as cargo's. For now that is mostly Go, whose failures name the test function; a test the output names in words, such as an RSpec example, never counts. - Checks the project lists as broken. A failed run of a command in the trusted
.jouet/project.toml's[checks.broken], as written or throughjouet test --cmd --, counts the same way, with the reason the file gives. ANAMEwritten as a word of its own in the listed command stands for the one or more tests a run names there, though not options:mise run test:unit NAMEcoversmise run test:unit forms_tests. See the project's checks. - A run still going.
jouet testmarks itself in the session's state while it runs. While its process is alive, test failures and partial runs do not hold up finishing: the run's result decides the next stop. A run killed before it records anything leaves a mark whose process is gone, which holds nothing up. - Back to files that passed. A failure at other files than the ones on disk now does not count when a pass that would clear it ran at exactly these files. So checking that a test fails without a fix, then putting the fix back, does not leave finishing waiting on the failure that proved the test.
Changes after a pass
- A change after the last pass. When the newest test run passed and files have changed since, with no test run passing at the files now, the hook blocks once and asks for the tests the change affects (
jouet test --affectedwhere it can select, or the tests for the code changed), or for a reason none are needed. Any passing test run at the files now is enough, partial or full. Lint and build runs are not asked about, and nothing is asked in a session that has run no tests. - A small fix. A change of at most 50 lines of code against the merge base with its base branch (added plus removed, committed or not, untracked files whole, prose left out) is not asked about after its last edit: an agent that tested a small fix once is not sent back for its last few lines. A failed run still holds it up. A branch that is its own base, such as work committed on
mainwith noorigin/main, or a change to a binary file, is never a small fix, since its size cannot be told; nor is one with a merge since its base, which brings in code the change did not write. - A change to prose alone. A change only to Markdown, reStructuredText, AsciiDoc or plain-text files, or to a licence, changelog, notice or authors file, asks for no test run: the tree keeps a second hash that leaves those files out, and a pass whose hash matches it still counts. An hk check still asks to lint them.
- Formatting after a pass. Files a formatter or a commit's hooks rewrote count as tested when the files before them were: the hooks keep the tree before and after a Bash call whose every command is a formatter (
cargo fmt,rustfmt,gofmt,go fmt,black,ruff format,prettier --write,hk fix), a commit (git commit,jouet commit, whose hooks may fix what they check),git add, or a command that leaves the files as they are (git status,git diffandgit logwithout--output,git push,git checkout -b NAMEorgit switch -c NAMEwith no start point, andcargo clippywithout--fix, also asjouet test --cmd -- cargo clippy), with pipes intotailorhead. A call consisting only ofgit status,git diff,git show,git log,head,tail, ortrueis not treated as formatting; a concurrent edit still needs a check. The call is parsed as a shell script, so a multi-line commit message is one word. Socargo fmtafter the tests, or hk fixing a file as it commits, needs no second test run, and an hk check that passed before still counts. A call with anything else in it, acd, or a redirection to a file other than/dev/nullis not formatting. This applies to Claude Code, whose hooks run before and after each call. - A merge after a pass. A merge that changes code is a change like any other: run the relevant checks on the merged tree. Separate branches can pass on their own while their combined changes fail.
- The tree. Each run records the tree it ran against: a hash of the content of every tracked file, staged or not, and of every untracked file that is not ignored, built from
git ls-files -sandgit hash-object(without-w) for files changed or added in the work tree, so nothing is written to the repository. A commit with the same content hashes the same, so committing what was tested, new files included, keeps the tree. Ignored files are left out, and so are untracked ones when there are more than 1,000 of them. A scratch file left in the repository and not ignored counts as a change. (git stash creategives a real tree id, but writes objects on every check.) - hk. In a repository with an hk config, a passing hk check (
hk check,hk run checkorjouet diag check) covering the changed files is remembered with its tree; if files change after it, the hook asks for another check. A later check of only some files does not undo it: at the files it passed at, it still stands. A failing one is a lint failure like any other. See one stop gate for running hk's own stop hook inside jouet's.
Work trees
- Which work tree. A run counts for the work tree it ran in, after any
cdbefore it:cd ~/src/app && cargo test --workspaceis a full run of~/src/app, whichever repository the session started in. Each work tree is judged by its own runs, and a block names each tree other than the session's own. After acdjouet cannot follow (cd "$DIR",cd -), the run is not recorded. - Subagents. Claude Code runs its subagents (the Agent tool) in the lead's session, so their runs go into the same state, and a run counts at its tree whoever ran it: a subagent's passing tests in the session's own work tree count for the lead. But a work tree only subagents ran checks in, such as a scratch repository one made under the session's scratchpad, is not held against the lead at its stop, and neither is one whose newest run was a subagent's, as when the lead checked a subagent's work there once and the subagent went on working: whoever ran there last is the one to finish it. Once the lead runs a check there again, it is judged like any other. The hooks tell a subagent's calls by their payload's
agent_id, andjouet testby the Bash call the pre-tool-use hook noted for it; ajouet testrun that cannot be placed counts as a subagent's. The session's own work tree is always judged. - Runs outside a repository. A run in a directory that is no git work tree has no tree to compare, and every such directory shares one entry, so a session working in a repository is not held up by them: a failed check in a scratch directory hours ago would otherwise block every stop after. A session that works outside a repository itself is judged by them as before.
- A work tree the session left. A work tree other than the session's own is not asked about once the session has left it: nothing there is uncommitted (no staged or unstaged change, no untracked file that is not ignored), and the session has not run or started a check there for an hour, or the tree is its base exactly (HEAD is the merge base with its default branch), so no change of the session's is left there. Its runs judged work that has since been committed and left, such as a branch that merged and moved on. Something uncommitted there (in a submodule too), or any check run there, brings it back. The session's own work tree is always judged. For Codex, whose runs are read from the transcript at each stop, a run's time is the one its transcript line records.
- A work tree that is gone. The checks of a work tree whose directory no longer exists, such as a removed worktree, are not judged.
How it blocks
- Blocking once. The hook blocks with a one-line reason, and if the agent stops again straight away (the harness reports
stop_hook_active), its answer stands. A check that cannot pass never traps the agent. - Once per run. A reason is raised once for the run it is about: a failure once for that failing run, and files changed after a pass once for that pass. Changing files afterwards, or other runs passing, does not raise it again; a newer run does: the same command failing at other files, or a newer pass followed by more changes. The same failure of the same files again is not new. One block raises every reason that applies in every work tree the session ran checks in, the session's own first, up to five a tree, and says how many wait for the next stop; a tree's reasons are never left for a later turn, which might not touch it. Commands in its message are cut to 80 characters, but each run is recorded whole, so a command it suggests, such as
--baseline's, runs. The session's state keeps what each tree was told. Outside a repository, where there is no tree to compare, and for Codex, whose runs are read again from the transcript at each stop, every turn's stop is judged afresh. - Jobs left running. When the agent stops, the hook names the
jouet jobs that session started in this work tree that are still running, with thejouet job stopthat would stop them. That is a note for the user (Claude Code's and Gemini CLI'ssystemMessage; Cursor and Copilot have no such note at a stop), never a reason to block; when a failed check blocks the stop, the note goes into the reason too. A job knows its session fromJOUET_SESSION_ID,CLAUDE_CODE_SESSION_IDorCODEX_THREAD_IDwhen it was started.
Where runs are recorded
Claude Code reports each command's exit code to the post-tool-use hooks, which keep each kind's newest run, newest full pass and uncleared failures (the 16 newest of a kind) in $XDG_STATE_HOME/jouet/sessions/<session>.json. Updates take a lock, so commands finishing at once cannot drop each other's runs, and files untouched for a week are removed. Cursor, Gemini CLI and Copilot report exit codes too, each in its own way (below), and their runs are kept the same way.
Codex sends hooks no exit code, so its stop hook reads the commands in the last 8 MiB of the session transcript instead, and runs them through the same rules. That format is Codex's own: if it changes, the hook stops blocking rather than blocking wrongly. The transcript does not say which tree each command saw, so for Codex a change after the last passing test run is not asked about.
Known false positives and misses
- A check you expected to fail, such as a test written to fail first or a failure that is already on the main branch, still blocks once. Say so in the reply and stop again; later turns are not asked again until the tree changes or a check runs.
- A pass of a different partial selection clears a partial failure only when it is the same command over more names, with no options in either, or the failed selection without the plain names after it:
mise run test:e2e test_b test_apassing clearsmise run test:e2e test_afailing, andcargo test -p apassing clearscargo test -p a namefailing, a name filter that narrowed it. For cargo and nextest, whose name filters match part of a name, a shorter filter covers a longer one:cargo test affected::clearscargo test affected::cargo, andcargo test -p a -- cutoff latestclearscargo test -p a -- test_cutoff_x, each failed filter held by one that passed, though not with--exact. For runners that run every test below a name, a pass of that name covers them: pytest's directories and files (pytest testsclearspytest tests/a.py::t) and Django's dotted labels (python runtests.py admin_viewsclearsadmin_views.tests.T), also through amise runtask whose command starts one. Other runners need the name itself, sogo test ./adoes not clear./a/b.cargo test -p a -p bpassing does not clearcargo test -p afailing, which still blocks once. - A check piped through
head,tailorgrepexits with that command's status, not the check's, so withoutset -o pipefailit is not recorded at all: it neither clears an earlier failure nor fails one. Run it throughjouet testinstead, or withpipefail. - A command that changes tracked files and then runs the tests, such as a formatter in the same script, is recorded at the tree after it finished.
- Runs in the background, and checks run through a script the hook cannot see into, are not recorded.
Before pushing and landing
jouet does not ask for a local full run before git push, gh pr create or jouet pr land, which merges only once the pull request's checks have passed. Check that the configured CI checks cover the tests the change needs.
fnox
fnox keeps a project's secrets encrypted or in a vault and hands them to programs as environment variables. In a project that uses it (a fnox.toml, .fnox.toml or .config/fnox.toml in the working directory or above), jouet helps keep the secrets out of the transcript, which leaves the machine with every turn, and out of commits:
- Rules.
print-secretsandsecret-files(in the table above) deny the usual ways an agent prints a secret, and suggestfnox exec -- <command>. The hook reads the secret names and the plain providers' files from the fnox config on every call, never a value.env FOO=bar cmd,echo "$TOKEN" | gh auth login --with-tokenandfnox get NAME > filestay allowed. - The primer. The session-start context says that the project keeps its secrets in fnox. When the agent runs under
fnox proxy run, it says so instead: its secret variables hold placeholders, which are not broken credentials. jouet detects the proxy by theFNOX_PROXY_MCP=1thatfnox proxy runsets for its command. - Setup.
jouet setuprecommends starting the agent withfnox proxy run -- claude(orcodex,cursor-agent, and so on). Under the proxy the agent's environment holds only placeholders, and fnox puts the real values into the HTTPS requests its[[proxy.rules]]allow, so a printed variable gives nothing away. See fnox's credential proxy. - Commits.
jouet commitrunsfnox scan --valuesover exactly what it is about to commit, beside gitleaks' rules, which it runs in every project.
The rules are pattern matches, and they miss what they cannot see: a script that prints a variable, python -c 'import os; print(os.environ)', or a value an agent copies out of a file under another name. The proxy is the stronger protection, because without it the values are in the agent's environment.
The ledger
Every hook appends one compact JSON line to $XDG_STATE_HOME/jouet/ledger/ledger.jsonl. A line holds the harness, the session id, a 16-character hash identifying the work tree (never its path), the time, the tool, the command's shape, the exit code and duration where the post-tool-use hooks report them, and each rule's decision (deny, warn, record:<kind>, or the stop check's block, under the rule name verify). A recorded check also carries its scope (full or partial, never what it selected) and the tree it ran against. A deny, warn or override carries the rule's revision, a 12-character hash of its [[rule]] table and its script, so a rule's decisions can be told from an older revision's across builds; jouet rule list --json shows each rule's. Lines written before jouet kept these read as partial, with no tree. Self-winding measures each rule from the ledger, and drafts new rules from the patterns in it.
{"ts":1793380000,"harness":"claude","session":"s1","event":"pre-tool-use","tool":"Bash","shape":"git stash","decisions":[{"rule":"git-stash","action":"deny"}]}
{"ts":1793380090,"harness":"claude","session":"s1","event":"post-tool-use","tool":"Bash","shape":"cargo test -p <arg>","exit_code":101,"duration_ms":41200,"tree":"5d0c3f2a91b7e64c08f1a2b3c4d5e6f7","decisions":[{"rule":"checks","action":"record:test","scope":"partial"}]}A few commands add lines of their own: every jouet command run in an agent's session a jouet-run line with its name and its own exit code, never its arguments, for jouet stats; jouet test a test-run line for each run, hk's fixers after an edit a fix line with how many files they changed (how), and a command that uses something deprecated a deprecated line, so that a session is told about it once (deprecations). Claude Code's UserPromptSubmit hook adds a prompt line for each turn, with what started it: its class (user, ci-monitor, side-sessions, session-message or task-notification), its length in bytes, and for an Auto-fix event the kinds it reports (review-comment, ci-failure, conflict, autofix-enabled, or other) and how many prs, comments and failing checks it names. Those counts come from the app's own lines, never from the text it quotes from GitHub, and the line holds no PR number, comment or check name.
{"ts":1793380200,"harness":"claude","session":"s1","event":"prompt","prompt":{"class":"ci-monitor","kinds":["review-comment","ci-failure"],"prs":1,"comments":2,"checks":1,"bytes":6120}}- A shape never holds values. It keeps each command's program, up to two of that program's known subcommands (
git stash push,gh pr checks) and its flag names; for jouet itself, the known subcommands are every command this jouet declares. Every other positional (paths, task and script names,echo's words), flag values, message text, here-documents and environment assignments become placeholders, and anything token-like is redacted.GH_TOKEN=... git -C /repo commit -m "fix it"is recorded asgit -C <arg> commit -m <arg>. A loop, conditional,caseor subshell keeps its structure, with its list, patterns and names as placeholders:for pr in 1 2; do gh pr checks $pr; doneisfor <arg> in <arg>…; do gh pr checks <arg>; done(see compound commands). - Overrides. When a command is denied or warned about and the session runs a command of the same shape within ten minutes, and it goes ahead this time, the ledger records an
overrideof that rule. It is a heuristic for measuring rules later: same shape means the same program, subcommands and flags, whatever the values. Each denial or warning is counted once. - Rotation. The file is rotated at 4 MiB, and rotated files are removed after 30 days.
- Slow hooks. A hook that runs over its time budget (
[hooks] budget_ms, 500 ms by default for the tool hooks, ten times that for session start and stop) appends aslow-hookentry naming its event, how long it took and the budget, forjouet share. - Turning it off.
[hooks] ledger = falseorJOUET_HOOKS_LEDGER=0.
Explicit allow decisions are not recorded: under the floor an allow decides nothing. The stop check still keeps its own small state file.
Settings
Every hook, every rule and the jouet stats summary line can be turned off in ~/.config/jouet/config.toml ($XDG_CONFIG_HOME/jouet/config.toml) or with an environment variable:
[hooks]
enabled = true # JOUET_HOOKS: every hook
steer = true # JOUET_HOOKS_STEER: the pre-tool-use rules
prime = true # JOUET_HOOKS_PRIME: the session-start guide
verify = true # JOUET_HOOKS_VERIFY: record checks for the stop check
gate = "observe" # JOUET_HOOKS_GATE: observe records where the stop check would block; block blocks
ledger = true # JOUET_HOOKS_LEDGER: the ledger
graph = true # JOUET_HOOKS_GRAPH: re-index the files an agent edits in the code graph, when the repository has one
budget_ms = 500 # JOUET_HOOKS_BUDGET_MS: a tool hook slower than this is recorded as slow (session start and stop: 10×)
[rules]
disable = ["git-stash", "no-verify"] # JOUET_RULES_DISABLE=git-stash,no-verify; "all" for every rule but the enforced ones
enable = ["python-edit"] # JOUET_RULES_ENABLE=python-edit: built-in rules that are off by default
network = false # JOUET_RULES_NETWORK: let rules in hooks use the GitHub probes (jouet.pr.*); off so a hook never waits on the network
[edit]
fix_after = false # JOUET_EDIT_FIX_AFTER: format edited files with `hk run fix --safe`
fix_timeout_ms = 8000 # JOUET_EDIT_FIX_TIMEOUT_MS: then the fix is stopped
fix_steps = ["cargo-fmt"] # JOUET_EDIT_FIX_STEPS: only these hk steps (default: every fix step)
[stats]
summary = "quips" # JOUET_STATS_SUMMARY: quips, plain, or off for no line
[cost]
record = true # JOUET_COST_RECORD: keep how long each build, test and lint took as a git note on HEAD (see `jouet cost`)
sequences = false # JOUET_COST_SEQUENCES: record local session-linked jouet test timings (see `jouet cost --sequences`)
[experiment]
control_share = 50 # JOUET_EXPERIMENT_CONTROL_SHARE: percent of sessions that keep the old behaviour of each toggle under test (see `jouet stats`); 0 turns the arms off
watcher_share = 25 # JOUET_EXPERIMENT_WATCHER_SHARE: percent of Claude Code sessions told to run `jouet wait pr --until needs-agent` in the background after each push; 0 turns this arm off
drop_share = 0 # JOUET_EXPERIMENT_DROP_SHARE: percent of Claude Code sessions in which Auto-fix events that are only bot noise are blocked before the agent sees them; 0, the default, turns this arm off
[test]
baseline = "auto" # JOUET_TEST_BASELINE: auto compares a failing run with the merge base on its own (see `jouet test`), off only with --baseline
baseline_cmd_under_ms = 60000 # JOUET_TEST_BASELINE_CMD_UNDER_MS: a failing --cmd run shorter than this is compared at the merge base on its own
reuse_s = 0 # JOUET_TEST_REUSE_S: opt in to reusing a result for this many seconds (see `jouet test`); 0 always runs
[wind]
enabled = true # JOUET_WIND: self-winding, drafting rules from the ledger
[entire]
enabled = true # JOUET_ENTIRE: read Entire where it is set up (pickup, sessions, --why, pr findings, Entire-hosted repositories)
timeout_ms = 5000 # JOUET_ENTIRE_TIMEOUT_MS: each `entire` call for status, sessions, checkpoints, trails, findings and discussions
why_timeout_ms = 15000 # JOUET_ENTIRE_WHY_TIMEOUT_MS: each `entire why` for --whyA broken file, or a value jouet cannot read, falls back to the default for what it broke, so a typo never stops a hook working. [wind] and [share] have more settings, for promoting rules, reviewing sessions and checking memories; they are listed under self-winding's settings.
An organisation's enforced rules in /etc/jouet (JOUET_SYSTEM_DIR) are not turned off by disable. Turning the hooks off still turns them off: enforced rules take precedence over your configuration, but they are not a sandbox.
Self-winding is on after setup: the session-start primer shows at most two of its notes, such as a rule jouet drafted from the ledger. JOUET_WIND=0 turns it off.
The skill and agent-help
jouet skill prints the skill jouet setup --skill installs: about 30 lines saying when to reach for each command family, then how to work with jouet, starting with jouet agent-help for the installed version's commands. setup leaves the skill out by default because the primer covers it: with both, every request carried the command list twice, and Codex read the skill file before starting. --format agents-md prints the same text as a snippet for an AGENTS.md, for harnesses without skills or hooks.
jouet skill --init prints the jouet-init skill, which jouet setup --init-skill installs. An agent loads it only when it is configuring a project, so that workflow stays out of the session primer. For a harness without skills, jouet skill --init --format agents-md prints the same instructions to give the agent for that session.
jouet agent-help prints a guide for the whole tool in at most 40 lines of its own, one entry per command family with its subcommands by name. jouet agent-help COMMAND prints one command's usage in about 1 kB: when to reach for it, the invocations agents use, each with what it does, a more: line saying what --full adds, and the exit codes. jouet agent-help COMMAND --full prints the whole guide: that usage, then every subcommand with all its flags and details, and the output conventions. Agents used to read the whole guide, up to 7 kB, before a command's first use. With --json, text is the whole guide and usage the short one.
Both are generated from the binary's own CLI definition, so they describe exactly the installed version. When mbx, jactionlint, rtk, beads (bd), fnox or pitchfork is on PATH, the guide adds a line saying what each is for, instead of jouet duplicating it.
Comparing with and without jouet
When you run setup yourself at a terminal and it changes a file, it asks Would you like a prompt to compare this project with and without jouet? [y/N]. Paste the prompt it prints into a new agent session in the project. The agent proposes a reproducible task, a starting commit and an independent check, and asks you to approve the task, agent and model, number of paired runs, and time and token budget before running anything. It runs each pair from the same starting point in separate checkouts and sessions, without copying your credentials or changing your global agent configuration, and compares correctness, tokens and wall time. If the results suggest jouet underperforms, it offers to draft an issue or discussion, removing private project details and secrets and using a synthetic reproduction where needed. Review the draft; it posts nothing until you approve it.
jouet setup HARNESS --benchmark runs setup as usual and prints the prompt after its report (with --json, as benchmark_prompt); it cannot be combined with --dry-run or --uninstall (exit 2). Setup does not launch the benchmark or manage the agent's login. The question appears only when a person is at the terminal (stdin and stderr are terminals and no coding agent is detected), and never with --json, --dry-run, --uninstall or --benchmark, so unattended setup never prompts and its output stays the same.
Harness details
The sections below are reference for how the hooks fit each harness.
When jouet is not on PATH
Each hook's command checks that jouet runs first, and exits 0 silently when it does not, the way the Entire CLI's hooks do:
sh -c 'jouet --version >/dev/null 2>&1 || exit 0; exec jouet hook claude pre-tool-use'So a contributor without jouet, an app started with a shorter PATH than your shell's, or a version manager's shim for a jouet that is not installed (a project that pins jouet in mise.toml before mise install has run) gets no hooks rather than an error on every tool call. The check runs jouet --version, not command -v jouet, which a shim passes. setup says so when jouet is not on PATH where you run it. A stop handler with hk's stop hook merged in runs hk's alone when jouet is missing.
The command is sh -c because Codex runs hooks with your login shell, which may not be a POSIX one. On Windows, Claude Code runs hooks with Git Bash, which has sh; without Git Bash it falls back to PowerShell, where this command fails, as a bare jouet would when it is missing. Codex runs them with PowerShell, so its handlers also get a commandWindows: if ((Get-Command jouet -ErrorAction Ignore) -and $(jouet --version *> $null; $LASTEXITCODE -eq 0)) { jouet hook codex pre-tool-use }.
Cursor, Gemini CLI and Copilot read a hook's stdout as JSON even when it has nothing to say, so their guard prints {} instead: sh -c 'jouet --version >/dev/null 2>&1 || { echo {}; exit 0; }; exec jouet hook cursor stop'. Copilot's handlers have a bash and a powershell command. Cursor and Gemini CLI have one command, which they run with PowerShell on Windows, so a --global install made on Windows gets the PowerShell form; a --shared one gets sh -c, for everyone else who clones the repository.
The hooks find jouet on PATH rather than at the path of the jouet that ran setup. That path changes on every upgrade with mise, Homebrew and other version managers, differs between machines that share a settings file, and means nothing to anyone else in a --shared install. A hook pointing at a path that is gone would skip as silently, but on every machine after every upgrade.
Worktrees
Claude Code reads .claude/settings.local.json from the main checkout's root, also in sessions that run in a linked worktree such as the desktop app's .claude/worktrees/*, so --project writes it there even when you run it from a worktree. The skill is read through from the main checkout only when the worktree has no .claude/skills directory of its own; a repository that tracks .claude/skills has one in every worktree. On Windows Claude Code reads the local file from each worktree instead, and a new worktree starts without it unless the repository's .worktreeinclude lists it.
If you work across many repositories or worktrees, install with --global instead, and choose which hooks run with the settings.
Cursor, Gemini CLI and Copilot
Each has documented command hooks for the four events jouet uses, and jouet hook reads their payloads into the shape the rules see: their shell tools become Bash (Shell, run_shell_command, bash, powershell), their file tools Read, Grep, Edit and Write, with Claude Code's field names, so a rule matches the same calls in every harness. What each cannot do:
| Cursor | Gemini CLI | Copilot | |
|---|---|---|---|
| Session start | sessionStart | SessionStart | sessionStart |
| Before a tool | preToolUse: Shell, Read, Grep | BeforeTool: run_shell_command, read_file, grep_search, search_file_content, replace, write_file | preToolUse: bash, powershell, view, create, edit, str_replace_editor, grep, rg |
| Rule actions | deny; warn after the call (preToolUse can only allow or deny, so the warning waits for postToolUse) | deny; warn after the call (BeforeTool can only allow or deny, so the warning waits for AfterTool) | deny; warn before the call, as additionalContext |
| After a tool | postToolUse, postToolUseFailure (Shell, Read, Grep); exit code from tool_output | AfterTool; exit code from the Exit Code: N line of the result, absent for 0 | postToolUse (bash, powershell, view), postToolUseFailure (bash, powershell); exit code from <exited with exit code N> in the result |
| After an edit | afterFileEdit, which formats with [edit] fix_after but cannot report it | AfterTool: replace, write_file | postToolUse: create, edit, str_replace_editor |
| Stop gate | stop blocks with a followup_message, Cursor's next user message: once (loop_count), and only for a completed turn | AfterAgent blocks (decision: "block"), once (stop_hook_active) | agentStop blocks (decision: "block"), once (stop_hook_active) |
- Cursor does not enforce a denial of a file read today (Cursor staff confirmed it is broken), so a rule on
Readonly warns there in effect. ItsWritetool is not steered: it sends the whole file to hooks, which fails to start them for a large one. Cursor also runs the hooks in Claude Code'ssettings.jsonfiles: while jouet's Cursor hooks are installed, jouet's Claude Code hooks step aside in Cursor sessions (they tell byCURSOR_VERSIONwithoutCLAUDECODE), so each call gets one verdict. Cloud agents run only a repository's hooks, and notsessionStart. - Gemini CLI gives the model a shell command's exit code only as text, so a command cancelled, killed by a signal or moved to the background is not recorded. Its matchers are unanchored regular expressions, so jouet's are anchored, and each handler is named (
jouet-pre-tool-use, ...) sohooksConfig.disabledcan turn one off. - Copilot's hooks reference does not list its tools' arguments; jouet reads
view,createandeditby the names Copilot CLI sends today (path,view_range,file_text,old_str,new_str). A shell result without an exit code is not recorded. The cloud agent reads.github/hooks/from the default branch only, and its runner needs jouet onPATH. Copilot also reads a repository's.claude/settings.json; there jouet's Claude Code hooks have no way to tell Copilot from Claude Code, so a repository with both--sharedinstalls runs each hook twice. - Warnings are never turned into denials. A decision about a shell command, warnings included, goes to the ledger when the call is checked, as in every harness, so
jouet statsand winding see it whether or not the agent did. A warning held for after the call reaches the agent when the harness runs its post-tool-use hook for that call, which jouet installs for every steered tool, failures included; a call another hook denies never runs, and its warning stays in the ledger only. - Payloads in
test/fixtures/hooksfollow each harness's documentation; jouet's tests replay them without the harness.
opencode and Amp
jouet setup does not support these two, because neither has command hooks:
- opencode has only JavaScript and TypeScript plugins, and its V1 and V2 plugin APIs are incompatible.
- Amp has only TypeScript plugins, which replaced its
amp.permissionsrules withdelegate, the one way it ran an outside program before a tool call.
jouet's commands work there, but without hooks there is no primer, no steering and no stop check. Add the snippet jouet skill --format agents-md prints to their AGENTS.md; it points the agent at jouet agent-help.