# Session Orchestrator Mods

A mod changes Claude Code's own interface or behaviour: a status line, a band above the prompt, a toast, a hook on tool calls. Session Orchestrator ships one, **so-hud**, in [`mods/so-hud/`](../mods/so-hud/README.md). It is installed separately from the plugin and runs in Claude Code only — Codex, Cursor and Pi do not load it.

```text
/plugin install so-hud@kanevry
```

Requirements: Claude Code with mod support (tested with **2.1.296**), Node.js 24 or later, and the Session Orchestrator release that ships the mod, or later.

## so-hud

so-hud has two parts: a cockpit that shows state, and protection gates that check shell commands (the `Bash`, `Monitor` and `PowerShell` tools), file edits and tool results before they run or reach the model. A band above the prompt and the `/profile` command pick how strict the gates are (see [Profiles](#profiles)).

### Cockpit

A status line for the running session, a warning band when something needs attention, the number of peer sessions, and one beacon file per session that other sessions can read through `$.so.beacons()`. All Session Orchestrator state comes from `scripts/so-hud.mjs`; the mod never writes STATE.md, locks, dispatch records or events. Details: [`mods/so-hud/README.md`](../mods/so-hud/README.md).

The status line says `nicht eingerichtet` for a part that is not set up: `SO nicht eingerichtet` when no Session Orchestrator installation is found, `Vault nicht eingerichtet` when the repository's Session Config has no `vault-integration` block (`Vault ?` when `CLAUDE.md` or `AGENTS.md` cannot be read). An enabled vault mirror that has never run shows `Vault noch kein Lauf`. `teilweise gemessen` marks a measurement with a gap; a repository where no session has written events yet is not marked.

### Protection gates (0.2.0, #1551)

From 0.2.0 on, so-hud checks tool calls before they run. Installed 0.1.0 users get this with the update: outward actions now ask twice by default, secret-printing commands are refused, and on the default profile `ausgewogen` rule traps such as `git add -A` are refused too. Read [Upgrading from 0.1.0 with scope user](#upgrading-from-010-with-scope-user) before you update.

**SEC-008 traps** — commands that would print a secret: reading an env file (`.env`, `.env.local`, `.envrc`, `prod.env`, `.env-prod`) with `cat`, `less`, `head`, `tail`, `grep`, `rg`, `sed`, `awk`, `cut`, `sort`, `xxd`, `base64`, `diff` and similar (`.env.example`, `.env.sample`, `.env.template` and `.env.schema` are fine, and so are `grep -q`/`-c`/`-l` and `sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p'`, which print no values); an environment dump (a bare `env`, `printenv`, `set`, `export`, `export -p`, `readonly`, or `declare`/`typeset` without a name) whose output is not cut down to the key names by the very next stage (`cut -s -d= -f1` or `awk -F= '{print $1}'`); `printenv` of a token variable; `echo`/`printf` of a token variable such as `$TOKEN` or `$GITLAB_TOKEN`, also when later pipe stages only pass it on (`| base64`, `| tr …`); `docker inspect` without `--format`; or a token variable in a curl `Authorization` header argument or in a curl URL query. Commands inside `bash -c "…"` are checked too. Refused on every profile. The refusal names the safe replacement, for example `env | cut -s -d= -f1 | sort` instead of `env`.

**Rule traps** — `git add .` / `-A` / `--all` (PSA-004), `claude -p` without `env -u ANTHROPIC_API_KEY`, `${PIPESTATUS[` (empty in zsh), and `grep -c … || echo 0`. A toast on `locker`, refused on `ausgewogen` and `streng`, again with the replacement command.

**Outward actions** — `git push` to `main` or `master`, with force, with tags or as a delete; `glab mr merge` / `glab mr accept` / `gh pr merge`, and `gh api` / `glab api` calls that write to a `…/merge` endpoint, run a merge mutation, or write with a `branch=main`/`branch=master` field (for example a file or commit created through the API); `glab release create` / `gh release create`; `npm`, `pnpm`, `yarn`, `changeset` and `cargo publish`, including workspace forms (`npm -w <pkg> publish`, `pnpm --filter <x> publish`) and `npx --yes npm publish`; `vercel --prod`, `vercel promote`, `vercel rollback`; `supabase db push`, `supabase db reset --linked`, `supabase migration up --linked`; `fly deploy`; `railway up`. These are also recognised inside `bash -c "…"`, `sh -c`, `eval`, after `nice`, `timeout` or `sudo`, and across `\` line continuations. A `git push --dry-run`, and a publish or deploy with `--dry-run`, is not held, as long as no later `--no-dry-run` undoes it. A command line that contains an outward word (`git … push`, `publish`, `gh`/`glab … merge`, `deploy`, `--prod`, `gh`/`glab … release`) inside a construct so-hud cannot read — `$(…)`, backticks, `xargs`, a shell function, a shell started by path or with options before `-c`, a git alias defined with `-c alias.…` — is held as well. Where a person can answer (the terminal, or an attached desktop, IDE or phone surface), the call is held and the owner confirms in two steps within 30 seconds — two questions in Claude Code's own question dialog. Before the second question so-hud measures the facts again; if they changed (for example a new commit to push), it refuses and asks for a new confirmation. Only after the second confirmation does the original call run, exactly once. Declining, closing the dialog or letting the 30 seconds run out refuses it.

Only a plain push of a feature branch runs without a question (#1569). Plain means all of this holds:

- **Options:** only `-u`/`--set-upstream`, `-q`/`--quiet`, `-v`/`--verbose`, `--progress` or `--no-progress`, written out exactly.
- **Destination:** at most a remote name and one branch name (no `:`, no `+`, not `@`, not `main` or `master`, also not `Main` or `MASTER`, not also a tag name); or no branch and an upstream that is such a branch; or `HEAD`, written in capitals, for the branch checked out. `git push origin HEAD` and `git push -u origin HEAD` therefore run without a question on a feature branch. `HEAD` and a bare `git push` stand for the branch so-hud measured. A branch the push names runs without a question only when git resolves it to exactly `refs/heads/<name>`: a branch name that is a symbolic ref to another branch (`feat/sym` pointing at `main`, which git would push as `feat/sym -> main`), or that a ref like `refs/feat/x` shadows, asks (#1570). Lowercase `head` asks: on a case-insensitive filesystem git reads it as `HEAD`.
- **Earlier commands that are not git:** so-hud measures the branch and the push configuration before the line runs. As soon as an earlier command on the line is not a git call, every push form asks, also one that names its branch (`npm test && git push origin feat/x`, `./switch.sh; git push origin HEAD`, `printf … >> .git/config; git push origin feat/x`): that command may have changed the branch or the push configuration. A command inside `$(…)` or backticks counts too (`x=$(./switch.sh) && git push`); `h=$(git rev-parse HEAD)` runs only a git call. A `cd` or a plain assignment such as `x=1` runs no command and does not count. An earlier output redirection counts like such a command, also on an allowed git step, a `cd`, an assignment or `exec` (`git log -1 --format=… >> .git/config; git push origin feat/x`, `cd . > .git/HEAD; git push origin HEAD`, `exec 3>>.git/config`): it may write `.git/config` or `.git/HEAD`. Only these write nothing and do not count: input redirections (`<`, `N<`, `<<`, `<<<`), process substitutions (`<(…)`, `>(…)`), `>`, `>>` or `&>` with any descriptor onto exactly `/dev/null` (`2>/dev/null`, `&>/dev/null`), and `>&` from stdout or stderr onto stdout or stderr (`2>&1`, `>&2`). Every other form asks, for example `>&3`, `&>>/dev/null`, `>|`, `<>`, `<&` or a quoted `"/dev/null"`. A `>` inside quotes (`git commit -m "a > b"`) is no redirection.
- **Remote:** a plain name that is defined in git config (`remote.<name>.url`). A remote that exists only as a legacy `.git/remotes/<name>` file asks.
- **Earlier git calls on the line** only read or work locally, an allowlist: `add`, `commit`, `status`, `rev-parse`, `ls-files`, `branch --show-current`, `tag -l` or `tag --list` with no other word (listing only), and `checkout -- <paths>` (restoring files; `checkout main -- <path>` asks); `fetch` only with no option and no word containing `::` (`git fetch`, `git fetch origin`, `git fetch origin main`); `log`, `show` and `diff` only when every option is a display option: `--oneline`, `--stat`, `--shortstat`, `--numstat`, `--name-only`, `--name-status`, `--summary`, `--cached`, `--staged`, `--no-color`, `--decorate`, `--graph`, `--abbrev-commit`, `-s`, `--no-patch`, `-p`, `-u`, `--patch`, `-q`, `--quiet`, `--`, `--no-ext-diff`, `--no-textconv`, a count (`-3`, `-n3`), or `--format=`, `--pretty=`, `--color=` or `--max-count=` with its value in the same word. Every other option asks: `--output` (also as `--output <file>`) writes a file such as `.git/HEAD`, `--ext-diff` and `--textconv` run a configured program, and on `fetch` `--upload-pack` (also abbreviated, `--upload=`) runs a program and `--set-upstream` moves the upstream. A git call inside `$(…)`, backticks, `<(…)` or `>(…)` counts as an earlier call (`echo $(git switch main) && git push …` asks), and a `git-<name>` program run by name or path never counts as local. `commit` counts only with options that open no editor and read no patch interactively (`-a`, `-q`, `-s`, `-n`/`--no-verify`, `-o`, `-i`, `-v`, `--allow-empty`, `--amend`, `--no-edit`, `--author`, `--date`, `--cleanup`, `--trailer`, `-S`/`--gpg-sign`, `--no-gpg-sign`, `-u`, `--dry-run`, `--short`, `--porcelain`, short bundles such as `-am`) and with a message (`-m`, `-F`, `-C`, a plain `--fixup=<commit>`, or `--amend --no-edit`): `git commit` alone, `-e`, `--amend` without a message, `-c`, `--fixup=amend:`/`reword:`, `--squash`, `-t`, `-p` and `--interactive` ask, because they run `core.editor`, the pager or `interactive.diffFilter`. `add` counts only with `-A`, `-u`, `-f`, `-v`, `-n`, `-N`, `--all`, `--update`, `--force`, `--dry-run`, `--intent-to-add`, `--ignore-errors`, `--ignore-missing`, `--ignore-removal`, `--sparse`, `--refresh` or `--chmod=±x`; `-p`, `-i`, `-e` and `--renormalize` ask (#1573 input). `log` and `show` may also verify signatures (`--show-signature`, or a `--format=`/`--pretty=` value with a `%G` placeholder): that is local, but runs `gpg.program` (see Repository configuration).
- **Hooks:** `add`, `commit`, `status`, `diff`, `fetch` and `checkout -- <paths>` can fire a git hook, and a hook can run any program, which may move the branch or rewrite the push configuration. When one of them runs earlier on the line, every push form asks unless so-hud measured in the push's folder that none of the hooks those steps can fire exists (#1574). Only those names are looked up — for `git commit`: pre-commit, prepare-commit-msg, commit-msg, post-commit, post-rewrite, reference-transaction, post-index-change, pre-auto-gc — in the hooks directory (`git rev-parse --git-path hooks`, which follows `core.hooksPath`), as `<name>` or `<name>.<ext>`, never `*.sample`. In a husky v9 repository (`core.hooksPath` ending in `.husky/_`) a hook counts only when the user's own `.husky/<name>` exists, and only after so-hud confirmed that `_/h` and the stubs are husky's own files; any other content counts as a hook. A hook defined in git config (`hook.<name>.command` with `hook.<name>.event`, includes followed) counts when its event is one of those names; an undocumented `hook.*` key asks. A git step the map does not know, an unrecognised layout or a failed read counts as "hooks may run". Such a step in a folder other than the push's, or in an unknown one, makes every push form ask whatever was measured, because it fires the hooks of that folder (`git -C ../b commit -m x && git push origin feat/x`, `cd ../b && git commit -m x && cd ../a && git push origin feat/x`, `git commit -m x && git -C ../a push origin feat/x`). Folders are compared as written, so `git -C . status && git push` asks too; `cd sub && git commit -m x && git push origin HEAD` runs both in `sub`. In a husky repository `git commit … && git push …` on one line asks only when a hook the commit can fire exists under `.husky/` (for example `.husky/pre-commit`); a push in a separate call does not ask for hooks.
- **Repository configuration** (#1573): a key in the repository's git config can make a local step run a program — `core.fsmonitor` for `status` and `diff`, `diff.external` and `diff.<x>.textconv` for `diff` and `log -p`, `filter.<x>.*` for `add` and `checkout -- <paths>`, `core.sshCommand`, `remote.<r>.uploadpack` and `credential.helper` for `fetch`, `gpg.program` for `commit` — and that program may move the branch after so-hud measured it (measured with git 2.56.0: `core.fsmonitor` pointing at a script that runs `git switch main` left a repository on `main` after `git status`). When any git call runs earlier on the line, so-hud reads every configured key once (`git config --null --show-scope --name-only --list` in the push's folder) and every push form asks unless each key of the scopes `local`, `worktree` and `command` (includes followed) is on an allowlist: `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, `core.ignorecase`, `core.precomposeunicode`, `core.sparseCheckout`, `core.sparseCheckoutCone`, `index.sparse`, `core.hooksPath` (judged under Hooks), `extensions.worktreeConfig`, `user.name`, `user.email`, `gc.auto`, `maintenance.auto`, `submodule.recurse`, `push.*`, `remote.pushDefault`, `branch.<b>.remote`/`merge`/`pushRemote`/`vscode-merge-base` and `remote.<r>.url`/`pushurl`/`fetch`/`push`/`mirror`/`glab-resolved*`. Any other key asks, `include.path` included; a failed or unparseable read asks too. Because a git step runs the configuration of the folder it runs in, any git step in a folder other than the push's, or in an unknown one, makes every push form ask, also one that fires no hook (`git -C ../b diff && git push origin HEAD`, `cd ../b && git diff; cd ../a && git push origin HEAD`); folders are compared as written, so `git -C . log && git push` asks too. A `remote.<r>.url` or `pushurl` whose value has the `<transport>::` form asks too: `git fetch` starts `git-remote-<transport>` from `PATH`. The values are read in the same call and never shown. The `global` and `system` scopes hold the host's own helpers, so they are judged by a denylist instead: `core.fsmonitor` other than a boolean, `diff.external`, `diff.<x>.textconv`/`command`, `core.sshCommand`, `core.askPass`, `core.gitProxy`, `remote.<r>.uploadpack`/`receivepack`/`vcs`, `uploadpack.packObjectsHook`, a remote URL in the `<transport>::` form or a `url.<base>.insteadOf` rewriting into one, a `filter.<x>.clean`/`smudge`/`process` other than git-lfs's own values (`git-lfs clean -- %f`, `git-lfs smudge -- %f`, `git-lfs filter-process`), a `credential.helper` other than an empty value, `osxkeychain`, `manager`, `manager-core`, `wincred`, `libsecret`, `store`, `cache` or `gh`/`glab auth git-credential` (bare or from `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`), `gpg.program` when a `commit` on the line signs (`commit.gpgSign` on there, or `-S`/`--gpg-sign` on the commit) or a `log`/`show` on the line verifies signatures, and `log.showSignature` on behind any `log` or `show` (it makes them verify). Not judged there: `core.pager` (no step's output is a terminal), `core.editor` (a commit that would open one asks as a step), includes (the included entries are judged themselves). A submodule or embedded repository has a configuration of its own that `git status`, `git diff` and `git fetch` run when they step into it, so a push behind a git step also asks when the index of the push's folder holds any gitlink (mode 160000, also an embedded repository added without `.gitmodules`), counted with `git ls-files --stage`; a failed count asks too.
- **`git -c`**, on the push and on every earlier git call on the line, sets nothing other than `user.name`, `user.email`, `core.quotepath` or `color.*`.
- **Variables:** every variable set on the line up to the push — `NAME=value`, `NAME+=value` or `NAME[i]=value` in front of a command or as its own statement, `env -u NAME`, or through `export`, `declare`, `typeset`, `local`, `readonly`, `unset`, `let`, `read`, `printf -v`, `mapfile`, `readarray`, `getopts` or `vared` (#1571; a `declare -n` nameref counts the name it points to) — has a harmless name: a single lowercase letter; `rc`, `out`, `msg`, `sha`, `ref`, `head`, `base`, `branch`, `res`, `ok` or `tmp`; or one or two characters, an uppercase letter followed by at most one uppercase letter or digit (`D`, `WT`, `L1`, `TZ`). Every other name asks — `HOME`, `PATH`, `XDG_*`, `GIT_*`, `SKIP_PRE_PUSH` — and so do `source`, `.`, `alias` and `hash`.
- **Folder:** a `cd` earlier on the line moves the folder so-hud measures in only when its effect is certain. A `cd` in a subshell `( … )` or a `{ … }` group, in a background job, in a pipeline, after `||`, behind a prefix such as `if` or `command`, or written as `cd -`, `popd` or with an option makes the folder unknown, and the push asks. So does a relative `cd sub` (not starting with `.`) while `CDPATH` is set in so-hud's environment: the shell looks it up in `CDPATH` first (#1573 input). A zsh `cdpath` set only in the session shell's own startup files is not seen.
- **Push configuration** is the standard one. so-hud reads it with `git config` in the push's folder: every `push.*` key, `remote.<remote>.push`, `remote.<remote>.mirror`, `remote.pushDefault`, `branch.<branch>.pushRemote` and `submodule.recurse`. It passes only when `push.default` is unset or `simple` (or `current`, when the push names its branch), `push.autoSetupRemote` is the only other `push.*` key set, `remote.<remote>.push` is unset, `remote.<remote>.mirror` and `submodule.recurse` are unset or false, and — when the line names no remote — `branch.<branch>.pushRemote` and `remote.pushDefault` are unset or name the same remote. Anything else asks: `push.default` set to `upstream`, `tracking`, `matching` or `nothing`, `push.followTags`, `push.pushOption`, `push.recurseSubmodules`, a push refspec, a mirror remote, a push remote that points elsewhere.

An input redirection in front of the command is read through: `</dev/null git push origin feat/x` is `git push origin feat/x`. Everything else is held: for example `git push origin head`, a `src:dst` refspec, `--no-verify`, push options (`-o`, which can merge on GitLab), abbreviated options such as `--mirr`, `--git-dir`/`--work-tree` or `GIT_DIR`-style variables, a push after `git switch`, `git checkout <branch>`, `git tag <name>` or `git config` on the same line, and every push for which one of the `git` reads behind this decision failed.

**Collision guard** — before `Edit`, `Write` or `NotebookEdit`, so-hud checks whether the file lies in the declared wave scope of another live Session Orchestrator session in the same working copy. Peers in another worktree never collide, and files outside the repository are not checked. A peer whose scope is not known shows a line in the band instead. The check uses the last measurement of `scripts/so-hud.mjs` (refreshed every 15 seconds); before the first successful measurement, or in a repository without Session Orchestrator, there is nothing to check against and the edit runs.

**Secret shield** — a tool result that contains a GitLab, GitHub, Anthropic, OpenAI, Stripe, Slack, AWS or Google token, or a private key, reaches the model as `[<kind> entfernt · sha256:<12 hex>]`, and a toast asks to rotate the token.

### Profiles

The `strictness` setting picks the profile; the default is `ausgewogen`.

| Check | `locker` | `ausgewogen` | `streng` |
|---|---|---|---|
| SEC-008 trap | refused | refused | refused |
| Rule trap | toast, the call runs | refused | refused |
| Outward action (interactive) | asked twice | asked twice | asked twice |
| File in a peer's wave scope | toast, the edit runs | asks: [Trotzdem] [Worktree vorschlagen] [Abbrechen] | refused, with a hint to work in an own worktree |
| Secret in a tool result | replaced, toast | replaced, toast | replaced, toast |
| The gate itself fails | refused, except calls that are neither outward nor SEC-008 | refused | refused |

Any other `strictness` value falls back to `ausgewogen`, the default. The texts in the dialogs, toasts and refusals are German for now.

**Choosing a profile (#1552).** Until a profile is chosen, a band above the prompt shows `Profil: ausgewogen (Vorgabe, noch nicht gewählt)`, one line on what the profile does, one on what holds on every profile, and three buttons: `locker`, `ausgewogen`, `streng`. The buttons have no hotkeys, because a digit typed into an empty prompt would press one. A press saves the choice in so-hud's own `strictness` row of `/config`; so-hud reloads with it, and every new session starts with it, without the band. A `strictness` set by hand in `/config` counts as chosen too.

`/profile` answers without a model turn: it shows the profile, its effect, what holds on every profile and where to change it. Its text is stored in the conversation like any command output, so the model reads it on its next turn. In the terminal it also opens the band with the three buttons and `Schließen`, which hides the band for the rest of the session; while no profile is chosen, the band returns at the next session start. Band and `/profile` work without a Session Orchestrator installation.

The profile steers only so-hud's own gates. Session Orchestrator's own guards — the destructive-command guard, the pre-push hook, the scope guard — do not read it.

### Upgrading from 0.1.0 with scope user

so-hud 0.1.0 only displayed state: its `tool.check` hook passed every call through. 0.2.0 adds the protection gates above. Outward actions such as a push to `main`, a merge (also through `gh api` / `glab api`), a release or a publish ask for two confirmations within 30 seconds, and SEC-008 traps refuse commands that would print a secret. Both hold on every profile.

`/plugin install` uses scope `user` unless told otherwise, and an update at that scope changes every Claude Code session on the host at once. That includes coordinator or lead sessions that merge through the API once the owner has said so: from the update on, each such merge waits for the two confirmations. Before you update:

1. Pick the `strictness` per host. 0.1.0 already has this row in `/config` (it changed nothing there yet), so you can set `locker`, `ausgewogen` or `streng` before the update; after it, the band above the prompt and `/profile` do the same. The profile decides rule traps and collisions, not outward actions or SEC-008 traps (see [Profiles](#profiles)).
2. Try 0.2.0 in one session first: load it from a checkout with `claude --plugin-dir ./mods/so-hud`, or install it at project scope (`claude plugin install so-hud@kanevry --scope project`) in one repository.
3. Only then update the user-scope install (`claude plugin update so-hud@kanevry`) and restart the sessions.

### Receipts

Every decision on an outward action appends one line per held action (a command with a push and a publish writes two lines), and every collision that was asked about appends one line, to `~/.config/session-orchestrator/hud/aktionen.jsonl`. The file has mode 0600 and its directory 0700. It is only ever appended to, never rewritten, so other tools may append to it as well.

so-hud writes a receipt or a beacon only when `hud/` (and, for beacons, `hud/beacons/`) is a real directory owned by the current user, not a symlink, and appends only to a regular file with exactly one link: a symlink or a hard link in place of `aktionen.jsonl` is refused. The directory is checked through a handle opened with `O_NOFOLLOW`, so the check and the `chmod` hit the same directory. Where Node offers no `O_NOFOLLOW` (native Windows), so-hud checks with `lstat` instead and sets the mode by path; a link swapped in between those two steps would be followed (creating one there needs admin rights or Developer Mode), and the owner check is skipped because Node reports no user id there. Removing the session's beacon at the end follows no symlink. A process that can rename entries in `~/.config/session-orchestrator` itself could still swap `hud/` between the check and the write.

| Field | Content |
|---|---|
| `zeit` | ISO timestamp |
| `session_id` | the session that decided |
| `vorgang` | always `pane-aktion` |
| `aktion` | `aussenwirkung` (outward action) or `kollision` |
| `argv_sha256` | SHA-256 (hex) of the UTF-8 of `JSON.stringify([tool, text])`, the tool name standing where a program would stand in an argv: `["Bash", "<command>"]` (or `Monitor`, `PowerShell`) with the command exactly as the model sent it for `aussenwirkung`; `["Edit", "<file path>"]` (or `Write`, `NotebookEdit`) for `kollision`. Neither the command nor the path is written, because a command can carry a token |
| `exit` | exit code, or `null` |
| `profil` | `locker`, `ausgewogen` or `streng` |
| `ergebnis` | `ausgefuehrt` (ran after confirmation), `abgelehnt` (declined, dialog closed, facts changed, or refused by a later check), `verfallen` (30 seconds passed), `ohne-zeichenflaeche` (nobody to ask, see below), `fehler` (the question to the owner could not be shown; the call was refused and the owner did not decide, #1856) |
| `art` | optional short lowercase label |

The field set is closed: a receipt with any other field, or larger than 1 KB, is not written.

### Headless runs

so-hud asks wherever a surface is attached — the terminal, or a desktop, IDE or phone client. Only a run with no surface at all (a plain `claude -p`, or an Agent SDK run nobody is attached to) has nobody to ask. There so-hud writes a receipt with `ergebnis: ohne-zeichenflaeche` and lets the outward action run. What still checks it is outside so-hud: a push passes the repository's own pre-push hook if it has one, and a force push is blocked by Session Orchestrator's destructive-command guard. **Merges, releases, publishes and deploys are not checked by any Session Orchestrator gate in such a run.** SEC-008 traps, rule traps and the secret shield apply unchanged. An edit that collides with a peer's scope on `ausgewogen` is refused there, because nobody can answer the question.

### Failure direction

If the gate itself fails, the call is refused (fail-closed). The one exception is `locker`, where a call that is neither an outward action nor a SEC-008 trap runs.

## Limits

- **Sessions that do not run Session Orchestrator are invisible to the collision guard.** It knows peers only from the Session Orchestrator session registry and their declared wave scope. A plain Claude Code session, another agent CLI or an editor in the same working copy can change any file without a warning.
- **The gates are a hurdle, not the authority.** The command-line gates — pre-push hook, destructive-command guard, CI — stay authoritative where they exist; a command that so-hud lets through still has to pass them. A command run outside Claude Code is never seen by so-hud.
- **Commands are read by pattern.** The command line is cut at `&&`, `||`, `;`, `|` and newlines; quoted parts of one word are joined. Comments, and heredocs whose body is only text (a quoted delimiter, or an unquoted one whose body holds no `$(`, `$((`, `$[`, backtick or `${…}` other than `${name}`, read by `cat` without writing a file, by `git commit`, or by `gh`/`glab` `issue`/`mr`/`pr`, including Claude Code's own `git commit -m "$(cat <<'EOF' … EOF)"`), are removed before anything is read, so a commit message or an issue body that mentions `git push` is no command (#1572, #1574). Elsewhere a `;` or a line break inside quotes still cuts the line, so `-m "wip; git push origin main"` asks (fail-closed). Constructs it cannot read (`$(…)`, backticks, `xargs`, shell functions, shells started by path) are held when an outward word appears in them. **Fail-closed fallback (#1570):** after the walker, any part of the line that spells an outward action so-hud knows — a tool word with its action in subcommand position: `git … push`, `gh`/`glab … mr|pr merge`, `… release create`, a forge API write to a ref or merge path, `npm`/`pnpm`/`yarn`/`changeset`/`cargo … publish`, `vercel --prod`/`promote`/`rollback`, `supabase db push`, `supabase db reset`/`migration up` with `--linked`, `fly deploy`, `railway up` — and formed no hit asks. That covers a wrapper or option so-hud does not know (`time -p`, `command -p`, `exec -a`, `nohup --`, `env -P`, `stdbuf`, `flock`, `lockf`, `doas`, `xcrun`, `script`, `coproc`, `trap "…"`), a `bash -c` that is not the first word, a shell fed on stdin (`sh <<<'…'`, `echo … | sh`), another interpreter (`python3 -c`, `node -e`, `perl -e`, also as a heredoc), a `case` branch, a command or git subcommand built from a variable (`${X}t push`, `git ${X}sh`), and git subcommands that run a shell command they are given (`rebase -x`, `bisect run`, `submodule foreach`). Not read as code there: the words of commands that never run them (`echo`, `printf`, `grep`, `cat`, `ls`, …) unless piped into one that may (`| sh`, `| awk`, `| sed`); a quoted argument of git or of a forge text command; the code of `node`, `python3`, `perl` and other interpreters on a line with no process API (`child_process`, `exec(`, `spawn(`, `subprocess`, `os.system`, `system(`, `qx`, …), so an analysis script that mentions `git push` runs; and a heredoc body `cat` writes to a file on a line that runs no file. Measured over 228,541 Claude Code transcript commands (21,928 of them hold an outward word): 305 now ask that did not, 275 no longer ask (the commit-message heredocs). Not recognised at all: git aliases from the user's own git config (`git p`), a command name taken from an environment variable set outside the line, scripts that publish or deploy (`npm run release`, `node scripts/release.mjs`, a file written and run without an outward word in its own words), and a process API spelled so the pattern does not match (`require('child' + '_process')`). Changes made by non-git commands earlier on the line (for example a script that edits `.git/config`) are not read either: so-hud measures before the line runs, so every push form behind such a command asks. Not measured: a program key git adds later in the global or system config (judged there by a denylist), and a program the push itself runs from the configuration (`core.sshCommand` on a bare `git push` with no git step before it); see Repository configuration above (#1573). The SEC-008 environment-dump trap goes by the command and its arguments. It catches `env` (also with `-0`/`-i`, in `( )`, or behind `command`/`builtin`/`VAR=x`) when no command follows, bare `set`, bare `export`/`readonly`, `declare`/`typeset` without a name, `typeset <SECRET_NAME>`, and `printenv` with no name or a secret one. Option settings like `set -e` and assignments like `export FOO=bar` stay free. A dump passes only if the stage directly after it cuts it to keys (`cut -s -d= -f1`, `awk -F= '{print $1}'`, `sed 's/=.*//'`); `printenv NAME` can never be filtered, because it prints the bare value. A secret that is echoed or fed as a here-string passes only into an allow list of silent consumers: digests, `openssl dgst`, `wc`, `read`, `curl --config -`, the stdin logins of named tools (`docker`/`podman`/`nerdctl`/`buildah`/`skopeo`/`oras login --password-stdin`, `helm registry login`, `crane auth login`, `gh auth login --with-token`, `glab auth login --stdin`), or a command whose every stdout target and stderr go to `/dev/null` (stdout alone for `echo`/`printf`; zsh writes to every listed target, so `>&2 >/dev/null` still prints). Runners such as `xargs`, `sh -c`, `env`, `sudo` or `timeout` never count as silent; anything else counts as printing it. The env-file readers are the listed ones only. The traps read a `bash -c '…'` behind another word too (`flock /tmp/l bash -c "cat .env"`), and a `printenv` with several names. A command written to avoid the patterns is not recognised.
- **The push gate leaves the push's own redirections alone.** An output redirection on the push itself (`git push origin feat/x > .git/config`) is not judged: the shell opens it before git starts, so it can only truncate the file, and git writes into it only after it has read its refs and config.
- **`Monitor` output is not shielded.** so-hud checks a `Monitor` command before it starts, but the lines it streams arrive as notifications, not as a tool result, so the secret shield does not see them.
- **The secret shield knows only the token formats listed above.** A secret in any other format passes through unchanged.
- **Engine:** tested with Claude Code 2.1.296. Other builds may differ in the mod API.
