Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6f74479
feat(hooks): connect foreground hooks to the terminal
worktrunk-bot Jun 19, 2026
a9f7bc2
chore: drop accidentally-committed .claude/settings.local.json
Jun 19, 2026
7053278
Merge branch 'main' into feat/issue-3093-interactive-foreground-hooks
max-sixty Jul 23, 2026
f0581ee
docs: regenerate plugin skills mirror after merge
max-sixty Jul 23, 2026
475e0dc
Merge branch 'main' into feat/issue-3093-interactive-foreground-hooks
max-sixty Aug 14, 2026
42e70fb
chore: drop the duplicate .claude/settings.local.json ignore entry
max-sixty Aug 14, 2026
8266ac4
docs(hook): name the forms that get the terminal and the ones that ge…
max-sixty Aug 14, 2026
5f0dae1
docs: cover `--foreground` in the stdin rule, and refresh the pgroup …
max-sixty Aug 14, 2026
dad71a2
refactor: drop `execute_shell_command`'s unreachable stdin branch
worktrunk-bot Aug 14, 2026
d69c43c
docs: repoint the stdin cross-references this change inverted
worktrunk-bot Aug 14, 2026
a0f534c
test: pin `wt hook post-* --foreground`'s inherited stdin
worktrunk-bot Aug 14, 2026
f4e84d6
docs(changelog): add the foreground-hook entry
worktrunk-bot Aug 14, 2026
0dd61e8
Merge origin/main into feat/issue-3093-interactive-foreground-hooks
max-sixty Aug 14, 2026
aef32b3
docs: state the stdin rule for a serial pipeline too, and pin it
max-sixty Aug 14, 2026
a521907
docs: the pipeline-stdin warning is about pipes, not terminals
worktrunk-bot Aug 14, 2026
897fcc7
feat(hooks): remove JSON-on-stdin, leaving one stdin rule
worktrunk-bot Aug 20, 2026
cd5e135
Merge remote-tracking branch 'origin/main' into feat/issue-3093-inter…
worktrunk-bot Aug 20, 2026
d9cc023
fix(process): scope `posix_command_separator` to the Unix spawn that …
worktrunk-bot Aug 20, 2026
e997403
docs(hook): a multi-key `pre-*` table is a concurrent group, so it re…
worktrunk-bot Aug 20, 2026
77528d9
docs: name what `prepare_steps`' `VarScope::All` still buys, and what…
worktrunk-bot Aug 20, 2026
b788dbf
Merge remote-tracking branch 'origin/main' into HEAD
worktrunk-bot Aug 31, 2026
1f3e3e6
Merge commit 'refs/tend/base/3129' into HEAD
worktrunk-bot Sep 1, 2026
1003304
docs: correct the shared-tty and EXEC-passthrough module docs
worktrunk-bot Sep 1, 2026
1709bfb
docs: retire the last EXEC-passthrough claims and complete PipelineKi…
worktrunk-bot Sep 1, 2026
b3318a1
Merge branch 'main' of https://github.com/max-sixty/worktrunk into HEAD
worktrunk-bot Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@

- **`wt config approvals add --yes` records approvals without a terminal**: the command refused every non-interactive run, so a container or CI job could not pre-approve a project it had just cloned. `--yes` now lists what it trusts and writes it, and a save it cannot make fails the command rather than warning and exiting 0. ([#3819](https://github.com/max-sixty/worktrunk/pull/3819))

- **A foreground hook keeps the terminal, so it can ask before continuing**: A `pre-*` hook now inherits wt's stdin exactly as an alias body already did, so `trust = "gum confirm 'trust this worktree?' && mise trust"` gets a prompt instead of an immediate EOF. That covers every type under `wt hook <type> --foreground` too. (Breaking: hooks no longer receive the JSON context on stdin at all — a hook that ran `json.load(sys.stdin)` must take the values it needs as template variables instead, e.g. `setup = "python3 setup.py {{ branch }} {{ repo }}"`, which every hook has always had. The forms that can't hold a terminal — detached `post-*` hooks, and the children of a concurrent group, who would race for one — now read EOF rather than JSON. Foreground steps share one stdin, so a step that drains it to EOF starves the steps behind it when that stdin is a pipe or a file.) Fixes [#3093](https://github.com/max-sixty/worktrunk/issues/3093). ([#3129](https://github.com/max-sixty/worktrunk/pull/3129))

- **`remote_repo` names the repository as the remote spells it**: `repo` is the directory on disk, so a renamed clone reports the new name. `{{ remote_repo }}` takes it from the primary remote's URL, available everywhere `owner` is and unset when no remote parses. ([#3745](https://github.com/max-sixty/worktrunk/pull/3745), thanks @canac)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Aside from the differences below, hooks and aliases behave the same.
| Force-bind escape | `--var KEY=VALUE` (deprecated in favor of `--KEY=VALUE`, but still force-binds) | None; smart routing is the only path |
| `--help` | `wt hook --help` lists hook types; `wt hook <type> --help` shows flags and arguments for that type | The template body is the documentation: `wt <alias> --help` redirects to `wt config alias show` / `dry-run`. `wt --help` and `wt step --help` list configured aliases alongside built-in commands |
| Inspection | `wt hook show [type] [--expanded]` | `wt config alias show <name>` / `wt config alias dry-run <name>` |
| Stdin | All template variables as JSON (parse with `json.load(sys.stdin)`) | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) |
| Stdin | A hook running in the foreground inherits parent stdin, same as aliases (interactive prompts work) — `pre-*` hooks, and any type under `wt hook <type> --foreground`; detached `post-*` hooks and concurrent children read EOF | Inherits parent stdin (pipes pass through; interactive TUIs like `wt switch` keep the tty) |
| Template-context extras | `hook_type`, `hook_name`, per-type operation vars (`base`, `target`, `pr_number`, …) | `args` on top of the shared base variables |

</details>
23 changes: 17 additions & 6 deletions docs/src/content/docs/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,19 +247,30 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree
setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}"
```

## JSON context
## Interactive hooks

Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express. Variables that are unset in a template are absent from the JSON too, so read the optional ones with a default — `branch` has none in a detached worktree:
A hook running in the foreground inherits wt's stdin, so it can ask before continuing:

```toml
[pre-start]
setup = "python3 scripts/pre-start-setup.py"
trust = "gum confirm 'trust this worktree?' && mise trust"
```

That covers `pre-*` hooks and any type under `wt hook <type> --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in.

Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline.

Logic that templates can't express belongs in a script, with the values it needs passed as arguments:

```toml
[post-start]
setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}"
```

```python
import json, sys, subprocess
ctx = json.load(sys.stdin)
if ctx.get('branch', '').startswith('feature/') and 'backend' in ctx['repo']:
import subprocess, sys
branch, repo = sys.argv[1], sys.argv[2]
if branch.startswith('feature/') and 'backend' in repo:
subprocess.run(['make', 'seed-db'])
```

Expand Down
2 changes: 1 addition & 1 deletion plugins/worktrunk/skills/worktrunk/reference/extending.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 17 additions & 6 deletions plugins/worktrunk/skills/worktrunk/reference/hook.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/worktrunk/reference/extending.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 17 additions & 6 deletions skills/worktrunk/reference/hook.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 17 additions & 6 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1803,19 +1803,30 @@ The `worktree_path_of_branch` function returns the filesystem path of a worktree
setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}"
```

## JSON context
## Interactive hooks

Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express. Variables that are unset in a template are absent from the JSON too, so read the optional ones with a default — `branch` has none in a detached worktree:
A hook running in the foreground inherits wt's stdin, so it can ask before continuing:

```toml
[pre-start]
setup = "python3 scripts/pre-start-setup.py"
trust = "gum confirm 'trust this worktree?' && mise trust"
```

That covers `pre-*` hooks and any type under `wt hook <type> --foreground`, except where the hook is a concurrent group — a table with two or more keys, whose children would race for the terminal, so each reads EOF instead. A detached `post-*` hook reads EOF too, having no terminal at all. Nothing is ever piped in — a hook reads its context through template variables, whatever form it runs in.

Foreground steps run in order and share one stdin, so a step that drains it to EOF — a `cat`, a `read` — leaves nothing for the steps behind it when that stdin is a pipe or a file. Under a terminal each step can prompt in turn. Steps accumulate across config files, so a user `[pre-start]` and a project `[pre-start]` form one pipeline.

Logic that templates can't express belongs in a script, with the values it needs passed as arguments:

```toml
[post-start]
setup = "python3 scripts/post-start-setup.py {{ branch }} {{ repo }}"
```

```python
import json, sys, subprocess
ctx = json.load(sys.stdin)
if ctx.get('branch', '').startswith('feature/') and 'backend' in ctx['repo']:
import subprocess, sys
branch, repo = sys.argv[1], sys.argv[2]
if branch.startswith('feature/') and 'backend' in repo:
subprocess.run(['make', 'seed-db'])
```

Expand Down
5 changes: 3 additions & 2 deletions src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,9 @@ fn format_alias_announcement(name: &str, entry: &AliasEntry) -> Option<String> {
/// `load_aliases` returns (a name only appears in the map if some source
/// defines it). When both are set, both bodies run — user first, then
/// project — and each runs under its own trust regime: user steps skip
/// approval and pass EXEC through (issue #2101); project steps require
/// approval and scrub EXEC.
/// approval, project steps require it. Directive passthrough no longer
/// varies by source: the EXEC directive file is retired (#3977), so every
/// child scrubs it and only the CD file is passed through.
pub(crate) struct AliasEntry {
pub user: Option<CommandConfig>,
pub project: Option<CommandConfig>,
Expand Down
Loading
Loading