Skip to content

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):

sh
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 added

Setup 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:

text
$ 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.

HarnessScopeHooksOptional 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
  • --project is 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 alongside settings.json but 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 .gitignore or .git/info/exclude.
  • --shared is for everyone who clones the repository. It implies --project and writes the repository's tracked hooks file (the --shared rows 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/NAME is a relative link to it. Where .claude/skills is 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-run shows both: would create .agents/skills/NAME/SKILL.md and would 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.json and .codex/config.toml, Cursor from .cursor/hooks.json, Gemini CLI from .gemini/settings.json and Copilot from .github/hooks/*.json, so --project is refused for them (exit 2): use --global, or --shared to install there for everyone. --global together with --project or --shared is 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 hooks is 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 bare jouet 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 run jouet setup claude again, with the scope flag you installed with if it was --project or --shared (jouet setup codex and the other harnesses work the same way). Adding --dry-run shows 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, as hk agent hooks installs 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, ArtifactComments and ArtifactData to permissions.deny in 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 in permissions.allow or permissions.ask is left out, so to keep one, add it to permissions.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.
  • --uninstall removes only jouet's entries, and in Claude Code's file the three Artifact denials and whatever the context budget added, then any group, event or permissions table that removing them left empty, and puts a merged hk stop hook back as it was. At project scope it removes them from both settings.local.json and settings.json, whichever setup wrote. A settings file left empty stays, as {}; Copilot's jouet.json, which is jouet's alone, is removed.
  • A skill is jouet's only while it is unedited. setup ends each SKILL.md it 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 --skill removes the usage skill an earlier version installed, and without --init-skill the jouet-init skill, each while it is unedited. --uninstall removes 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 or CLAUDE_CODE_AUTO_COMPACT_WINDOW already choose a window, top-level or under modelSettings.

  • 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 get skillOverrides: {"NAME": "user-invocable-only"}, which takes them out of the model's skill list but keeps /NAME for you. With fewer than 20 sessions, nothing is.

    Only what you added can be picked: an MCP server in .claude.json or in the .mcp.json of 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:

sh
jouet setup claude --compact-window 200k   # 100k to 1m, which is what Claude Code accepts
jouet setup claude --deny-unused

A 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 ​

EventClaude CodeCodexWhat jouet does
Session startSessionStartSessionStartAdds 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 startSubagentStartnoneGives 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 startsUserPromptSubmitnoneRecords 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 editPreToolUse (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 ReadPostToolUse (Bash|Read), PostToolUseFailure (Bash)noneRuns 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 editPostToolUse (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).
StopStopStopFinds 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) or jouet test --cmd -- CMD... (any other test or build). Give a build or test that can take over 2 minutes a Bash timeout of 600000, so it ends in that call. Commit only the paths you name with jouet commit <PATHS>... --message MSG, in the same call as their test run (jouet test ... && jouet commit ...). Put a step's edits in one jouet edit replace <PATHS>... --old TEXT --new TEXT (several files, repeated --old/--new pairs), chained with its test; never a Python script. Find code with jouet search PATTERN [PATHS] (matches grouped by function, within a budget) instead of grep -rn, and read definitions with jouet read PATH --symbol NAME --range A:B (each repeats) instead of sed -n ranges. Each call costs the whole context, so put independent reads and searches in one. List a PR's open review threads with jouet pr threads [PR]; reply to and resolve one with jouet pr threads reply <ID> [PR] --body TEXT --resolve. These need no lookup; for other commands, jouet agent-help COMMAND gives their usage.

jouet's hooks deny these and say what to do instead: sleep-poll (use jouet wait ci), ci-rerun (use jouet ci explain), review-threads (use jouet pr threads), git-add-all (use git add path/to/file ...), git-stash, no-verify, force-push (use git push --force-with-lease), print-secrets, secret-files (use fnox exec -- <command>), job-start, job-ready (use jouet 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 cost lists 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, and jouet pr threads with its reply, with a note that these need no lookup. jouet test --affected is named only where it can select tests.
  • Commit with the tests. jouet commit goes in the same call as the test run, which saves agents a request of their own for it.
  • Search and read by symbol. jouet search and jouet read --symbol are given with their usage, in place of grep -rn and sed -n ranges: 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-edit is on ([rules] enable = ["python-edit"]; it is off by default). The primer then says to use a jouet edit spec, 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] verify is on and [hooks] gate is block, 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 timeout of 600000, past which a call moves to the background. Codex: a large yield_time_ms or a loop on write_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, through updatedInput. 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 is piped-test-output, below.

A note follows the primer only when it applies:

  • where jouet cost has 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 sync fetches it;
  • in a fnox project, how to use its secrets, or that the session runs under fnox proxy run;
  • for each deprecated whole-file .luau rule, 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:

text
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:

RuleDeniesSuggests
sleep-pollsleep N together with gh pr checks, gh run view or a statusCheckRollup queryjouet wait ci
ci-rerungh run rerun (bare, --failed or --job), and gh api calls to a run's or job's rerun endpointsjouet ci explain, whose --rerun reruns the failed jobs
ci-pendingwarns (does not deny), after the call, when gh pr checks exited 8: checks are still pendingjouet wait ci rather than checking again
ci-failedwarns (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 answerjouet ci explain before reading logs or pushing a fix
review-threadsgh api graphql (or curl to the GraphQL API) with a reviewThreads queryjouet pr threads
python-editOff 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 notjouet edit
sed-line-editwarns (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 aloneyour edit tool, or jouet edit replace
stdin-hangin 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 latera here-document, a pipe or a file, or dropping the command
git-add-allgit add -A, --all, . or :/staging explicit paths
git-stasha 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 stashgit 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 -nfixing what the hook reports
force-pushgit push --force, -f or a +refspec without --force-with-lease--force-with-lease
reset-to-remotewarns (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 pushedgit merge START on the branch, or jouet stack sync
rebase-merged-basewarns (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 GitHubjouet stack sync, which merges the trunk in
print-secretsin 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 capturedfnox exec -- <command>
secret-filesin 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-secretsgit 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 MiBtaking the secret out; for a false positive, jouet commit --allow-secret RULE:PATH:LINE, gitleaks:allow or .gitleaksignore
scoped-lintwarns (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 suitejouet diag check --changed while iterating, and the full lint once at the end
affected-testswarns (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 selectjouet test --affected while iterating, keeping the full run for the end
piped-test-outputon 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 warnsjouet test --cmd -- CMD, with the command filled in
unbudgeted-searchwarns (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_limitjouet search PATTERN
rereadwarns (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 nlusing what it read earlier, or jouet search PATTERN FILE, which shows only matches the session has not seen
large-readwarns (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 nljouet read FILE --outline, then --symbol NAME
unbounded-querywarns (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 rowjouet db query
curl-jsonwarns (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 shjouet http GET URL --select '$.path'
job-starta 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 backgroundjouet 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-readysleep 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; donejouet 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-logswarns on tail -f of a *.log or *.out file, and on reading nohup.outjouet job logs ID -n 50
job-stopwarns on kill $(lsof ...), lsof ... | xargs kill, pkill/killall of a dev server, fuser -k and kill-portjouet job stop ID
claimed-pathwarns (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 branchleaving the path to that session, or coordinating first
pitchfork-startstarting 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 portpitchfork start ID, with the daemon named
pitchfork-stopkill $(lsof ...), lsof ... | xargs kill, pkill/killall, fuser -k or kill-port aimed at a declared daemon's port or command linepitchfork stop ID or pitchfork restart ID
pitchfork-logswarns on pitchfork logs --followpitchfork logs ID -n 50
trustjouet 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 themasking the user to run it in their own terminal
rule-fileschanging, 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 againjouet 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:

text
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:

toml
[hooks]
gate = "block" # JOUET_HOOKS_GATE=block

A 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 checks rule records cargo test, build, check, clippy and fmt --check (also through mbx), mise run tasks named test*, lint*, check*, build* or ci, npm/pnpm/yarn/bun test and scripts with those names, make/just targets, go test/vet/build, pytest, python -m unittest, vitest, jest, bats, jouet test (with --cmd, as the command it ran), hk check and a few common linters; add a script rule of your own with on = "post-tool-use" that calls jouet.record to count more.
  • Ruff previews. ruff check --diff and ruff check --fix-only are not counted as lint checks: the former reports a proposed diff, and the latter can leave violations unreported. Run ruff check to verify lint.
  • Full or partial. Each run is recorded with its scope: full when it covered the whole project, or partial with what it selected. For cargo, -p (unless the packages named are every workspace member), --test, --lib, --bin, --exclude, a test-name filter, --exact or --skip after --, and nextest's -E make a run partial; --workspace, a plain run in a single-package project, and a plain run at a virtual manifest without default-members are full. A plain cargo test at 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 ./..., and pytest, vitest or jest without paths or name filters, are full, as is python -m unittest with no tests named (or discover without a pattern or -k). jouet test is full, and jouet test --affected is partial, selecting --affected (see jouet test). jouet test --cmd -- CMD has the kind and scope of CMD, read through wrappers such as mise x --, env and timeout as 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 a cd the 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 test counts, cargo test; echo done does not. A jouet command that exits 2 (usage) or 4 (refused), such as jouet diag check where there is no hk config or jouet test with no runner it knows, checked nothing: it is not recorded, so it neither fails a check nor clears one.
  • jouet test keeps its own result. Piped, as in jouet 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. So jouet test records each run itself, in the session's state (found by CLAUDE_CODE_SESSION_ID or JOUET_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 a failing, then cargo test -p a passing). A bats run of a directory covers each .bats file in it (in its subdirectories too with -r), unless it picked tests with -f, --filter-tags or --filter-status: bats -j 8 test/ passing clears a failed bats test/rule.bats. A partial pass never clears a full failure: cargo test -p a passing after cargo test --workspace failed still blocks, and says only a full run clears it.
  • Failures that were there before. jouet test compares 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 --cmd when it did not go through jouet 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 from jouet test's own log, or from Claude Code's PostToolUseFailure payload.
  • Failures in tests the change does not reach. A failed jouet test run (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 --affected selects nothing in that directory, and no changed file is in it. The run says so on an unrelated: 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 --affected run 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 through jouet test --cmd --, counts the same way, with the reason the file gives. A NAME written 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 NAME covers mise run test:unit forms_tests. See the project's checks.
  • A run still going. jouet test marks 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 --affected where 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 main with no origin/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 diff and git log without --output, git push, git checkout -b NAME or git switch -c NAME with no start point, and cargo clippy without --fix, also as jouet test --cmd -- cargo clippy), with pipes into tail or head. A call consisting only of git status, git diff, git show, git log, head, tail, or true is 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. So cargo fmt after 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, a cd, or a redirection to a file other than /dev/null is 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 -s and git 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 create gives 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 check or jouet 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 cd before it: cd ~/src/app && cargo test --workspace is 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 a cd jouet 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, and jouet test by the Bash call the pre-tool-use hook noted for it; a jouet test run 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 the jouet job stop that would stop them. That is a note for the user (Claude Code's and Gemini CLI's systemMessage; 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 from JOUET_SESSION_ID, CLAUDE_CODE_SESSION_ID or CODEX_THREAD_ID when 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_a passing clears mise run test:e2e test_a failing, and cargo test -p a passing clears cargo test -p a name failing, 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:: clears cargo test affected::cargo, and cargo test -p a -- cutoff latest clears cargo 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 tests clears pytest tests/a.py::t) and Django's dotted labels (python runtests.py admin_views clears admin_views.tests.T), also through a mise run task whose command starts one. Other runners need the name itself, so go test ./a does not clear ./a/b. cargo test -p a -p b passing does not clear cargo test -p a failing, which still blocks once.
  • A check piped through head, tail or grep exits with that command's status, not the check's, so without set -o pipefail it is not recorded at all: it neither clears an earlier failure nor fails one. Run it through jouet test instead, or with pipefail.
  • 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-secrets and secret-files (in the table above) deny the usual ways an agent prints a secret, and suggest fnox 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-token and fnox get NAME > file stay 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 the FNOX_PROXY_MCP=1 that fnox proxy run sets for its command.
  • Setup. jouet setup recommends starting the agent with fnox proxy run -- claude (or codex, 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 commit runs fnox scan --values over 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.

json
{"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.

json
{"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 as git -C <arg> commit -m <arg>. A loop, conditional, case or subshell keeps its structure, with its list, patterns and names as placeholders: for pr in 1 2; do gh pr checks $pr; done is for <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 override of 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 a slow-hook entry naming its event, how long it took and the budget, for jouet share.
  • Turning it off. [hooks] ledger = false or JOUET_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:

toml
[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 --why

A 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
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:

CursorGemini CLICopilot
Session startsessionStartSessionStartsessionStart
Before a toolpreToolUse: Shell, Read, GrepBeforeTool: run_shell_command, read_file, grep_search, search_file_content, replace, write_filepreToolUse: bash, powershell, view, create, edit, str_replace_editor, grep, rg
Rule actionsdeny; 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 toolpostToolUse, postToolUseFailure (Shell, Read, Grep); exit code from tool_outputAfterTool; exit code from the Exit Code: N line of the result, absent for 0postToolUse (bash, powershell, view), postToolUseFailure (bash, powershell); exit code from <exited with exit code N> in the result
After an editafterFileEdit, which formats with [edit] fix_after but cannot report itAfterTool: replace, write_filepostToolUse: create, edit, str_replace_editor
Stop gatestop blocks with a followup_message, Cursor's next user message: once (loop_count), and only for a completed turnAfterAgent 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 Read only warns there in effect. Its Write tool 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's settings.json files: while jouet's Cursor hooks are installed, jouet's Claude Code hooks step aside in Cursor sessions (they tell by CURSOR_VERSION without CLAUDECODE), so each call gets one verdict. Cloud agents run only a repository's hooks, and not sessionStart.
  • 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, ...) so hooksConfig.disabled can turn one off.
  • Copilot's hooks reference does not list its tools' arguments; jouet reads view, create and edit by 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 on PATH. 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 --shared installs 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 stats and 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/hooks follow 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.permissions rules with delegate, 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.

MIT LicenseCopyright © 2026jdx.dev