feat: add worktree_create hook to prepare fresh git worktrees

Introduce the worktree_create lifecycle event, fired once just after
`docker agent run --worktree` creates a git worktree and before the
session starts. Each hook runs inside the new worktree so setup commands
(copy .env, install dependencies, warm caches) operate on the fresh
checkout. A blocking verdict aborts the run.

The input carries worktree_path, worktree_branch, and worktree_source_dir
(the repo root branched from) so hooks can copy untracked files git won't
carry over. Unlike most events it is dispatched from the CLI rather than
the run loop, because the working directory must be settled before the
runtime and session exist. The dispatch nil-guards loadResult to match
the surrounding cleanup convention.

Also fix hookWorkingDir to default to the executor's working directory
when a hook has no working_dir override, instead of inheriting the
process cwd. This was masked for runtime hooks (the process has already
chdir'd) but surfaced via the CLI-dispatched worktree_create event.

Includes schema, example, docs, and tests.
This commit is contained in:
David Gageot committed 2026-06-01 09:36:38 +02:00
1 parent 2d9f558abb
commit 85e6b74baa
10 files changed
+265 -5

No files matched your search

+7
View File
@@ -1038,6 +1038,13 @@
"items": {
"$ref": "#/definitions/HookMatcherConfig"
}
},
"worktree_create": {
"type": "array",
"description": "Hooks that run once, just after `docker agent run --worktree` creates a git worktree and before the session starts. They execute inside the new worktree (their working directory is the fresh checkout), and the worktree path, branch, and source repository root are passed in worktree_path / worktree_branch / worktree_source_dir. Use them to prepare the checkout: copy untracked files like .env, install dependencies, warm caches, before the agent begins. A hook may abort the run by blocking (decision=block / continue=false / exit code 2); stdout is surfaced as additional context. Dispatched from the CLI rather than the run loop because the worktree (and the working directory every downstream component captures) must be settled before the runtime and session exist.",
"items": {
"$ref": "#/definitions/HookDefinition"
}
}
},
"additionalProperties": false
+58
View File
@@ -27,6 +27,7 @@ import (
"github.com/docker/docker-agent/pkg/profiling"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/teamloader"
"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/tui"
@@ -390,6 +391,15 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
wd = wt.Dir
f.runConfig.WorkingDir = wt.Dir
out.Println("Using git worktree: " + wt.Dir + " (branch " + wt.Branch + ")")
// loadResult is nil for the remote backend; --worktree is mutually
// exclusive with --remote so this is belt-and-suspenders, matching
// the nil-guard used for cleanup throughout this function.
if loadResult != nil {
if err := f.dispatchWorktreeCreate(ctx, out, loadResult.Team, wt); err != nil {
stopToolSets(loadResult.Team)
return err
}
}
}
rt, sess, cleanup, err := b.CreateSession(ctx, loadResult, b.CreateSessionRequest(wd))
if err != nil {
@@ -426,6 +436,54 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
return runTUI(ctx, rt, sess, b.Spawner(rt), cleanup, f.tuiOpts(), opts...)
}
// dispatchWorktreeCreate fires the worktree_create hooks of the agent the
// run targets, just after the worktree is created and before the session
// exists. Unlike every other event, this is dispatched from the CLI rather
// than the run loop: the worktree (and the working directory the runtime,
// session, tools and snapshot machinery all capture) must be settled first.
// Hooks run inside the new worktree so setup commands (copy .env, install
// deps) operate on the fresh checkout. A blocking verdict aborts the run.
func (f *runExecFlags) dispatchWorktreeCreate(ctx context.Context, out *cli.Printer, t *team.Team, wt *worktree.Worktree) error {
agt, err := t.AgentOrDefault(f.agentName)
if err != nil {
return err
}
hooksCfg := agt.Hooks()
if hooksCfg == nil {
return nil
}
executor := hooks.NewExecutor(hooksCfg, wt.Dir, os.Environ())
if !executor.Has(hooks.EventWorktreeCreate) {
return nil
}
result, err := executor.Dispatch(ctx, hooks.EventWorktreeCreate, &hooks.Input{
AgentName: agt.Name(),
Cwd: wt.Dir,
WorktreePath: wt.Dir,
WorktreeBranch: wt.Branch,
WorktreeSourceDir: wt.SourceDir,
})
if err != nil {
return fmt.Errorf("running worktree_create hooks: %w", err)
}
if result.SystemMessage != "" {
out.Println(result.SystemMessage)
}
if result.AdditionalContext != "" {
out.Println(result.AdditionalContext)
}
if !result.Allowed {
msg := result.Message
if msg == "" {
msg = "a worktree_create hook blocked the run"
}
return fmt.Errorf("worktree_create hook aborted the run: %s", msg)
}
return nil
}
func (f *runExecFlags) loadAgentFrom(ctx context.Context, req runtime.LoadTeamRequest) (*teamloader.LoadResult, error) {
opts := []teamloader.Opt{
teamloader.WithModelOverrides(req.ModelOverrides),
+32 -1
View File
@@ -59,6 +59,7 @@ docker-agent dispatches the following hook events:
| `on_agent_switch` | When the runtime moves the active agent (transfer_task, handoff, return) | No |
| `on_session_resume` | When the user explicitly approves continuation past `max_iterations` | No |
| `on_tool_approval_decision` | After the runtime's approval chain (yolo / permissions / readonly / ask) resolves | No |
| `worktree_create` | After `docker agent run --worktree` creates a git worktree, before the session | Yes |
<div class="callout callout-info" markdown="1">
<div class="callout-title">Two compaction events
@@ -270,6 +271,7 @@ In addition to the common fields, each event ships its own payload:
| `on_agent_switch` | `from_agent`, `to_agent`, `agent_switch_kind` (`transfer_task`, `transfer_task_return`, or `handoff`) |
| `on_session_resume` | `previous_max_iterations`, `new_max_iterations` |
| `on_tool_approval_decision` | `tool_name`, `tool_use_id`, `tool_input`, `approval_decision`, `approval_source` |
| `worktree_create` | `worktree_path`, `worktree_branch`, `worktree_source_dir` (`cwd` is also set to the new worktree) |
Notes:
@@ -336,7 +338,7 @@ This is the symmetric counterpart of `pre_tool_use`'s `updated_input`, applied t
### Context-Contributing Events
For `session_start`, `user_prompt_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop`, hooks may set `hook_specific_output.additional_context` to inject text into the conversation. `turn_start` context is **transient** (recomputed every turn, never persisted); `session_start` context **persists** for the life of the session.
For `session_start`, `user_prompt_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop`, hooks may set `hook_specific_output.additional_context` to inject text into the conversation. `turn_start` context is **transient** (recomputed every turn, never persisted); `session_start` context **persists** for the life of the session. (`worktree_create` also surfaces stdout, but to the CLI user rather than the conversation — the session doesn't exist yet.)
### Before-Compaction Specific Output
@@ -599,6 +601,35 @@ At every transfer the runtime ships a snapshot of the previous agent's model end
`on_tool_approval_decision` fires after the runtime's tool-approval chain (yolo / permissions / readonly / pre_tool_use hooks / interactive prompt) has resolved a verdict for a tool call. `approval_decision` is `allow`, `deny`, or `canceled`; `approval_source` is a stable classifier of which step produced the verdict. Observational only — it gives audit pipelines a single, structured "who approved what" record without re-implementing the chain.
### Worktree-Create: prepare an isolated checkout
`worktree_create` fires once, just after `docker agent run --worktree[=name]` creates a fresh [git worktree]({{ '/features/cli/' | relative_url }}) and **before** the session starts. Each hook runs **inside** the new worktree — its working directory (and `cwd` in the input) is the fresh checkout — so setup commands operate on the new tree rather than your original one. The worktree path and branch are in `worktree_path` and `worktree_branch`, and `worktree_source_dir` carries the repository root it was branched from.
Use it to prepare the checkout before the agent begins: copy untracked files git won't carry over (`.env`, local config), install dependencies, or warm caches. Because the worktree lives under the docker-agent data directory — not next to your checkout — resolve the original files through `worktree_source_dir` rather than a relative path. A hook may **abort the run** by returning `decision: block` / `{"continue": false}` / exit code 2 (for example, when a setup step fails); plain stdout is surfaced as additional context.
```yaml
hooks:
worktree_create:
# Copy untracked dotfiles git won't bring into the new worktree.
- name: seed local env
type: command
command: |
INPUT=$(cat)
SRC=$(echo "$INPUT" | jq -r '.worktree_source_dir // ""')
[ -n "$SRC" ] && [ -f "$SRC/.env" ] && [ ! -f .env ] && cp "$SRC/.env" .env
echo "Prepared worktree"
# Install dependencies, aborting the run on failure.
- name: install dependencies
type: command
timeout: 600
command: |
if [ -f package.json ]; then
npm install || { echo '{"continue": false, "system_message": "npm install failed"}'; exit 2; }
fi
```
Unlike most events, `worktree_create` is dispatched from the CLI rather than the run loop, because the worktree (and the working directory the runtime, session, tools, and snapshot machinery all capture) must be settled before the runtime and session exist. See [`examples/worktree_create_hook.yaml`](https://github.com/docker/docker-agent/blob/main/examples/worktree_create_hook.yaml) for the full file.
### Pre-Compact: steer the summary
`pre_compact` fires just before the runtime compacts the session transcript. Its `source` field tells you why compaction was triggered:
+74
View File
@@ -0,0 +1,74 @@
#
# worktree_create hook
# ==========================================================================
#
# The worktree_create event fires once, just after
#
# docker agent run --worktree[=name] agent.yaml
#
# creates a fresh git worktree and BEFORE the session starts. Each hook runs
# INSIDE the new worktree (its working directory is the fresh checkout), so
# setup commands operate on the new tree rather than your original checkout.
#
# The hook Input carries:
#
# .cwd - the new worktree directory (also the hook's workdir)
# .worktree_path - same as .cwd, passed explicitly for convenience
# .worktree_branch - the branch checked out in the worktree (worktree-<name>)
# .worktree_source_dir - the repo root the worktree was branched from
#
# Use it to prepare the checkout before the agent begins: copy untracked
# files git won't carry over (.env, local config), install dependencies,
# warm caches, etc.
#
# A hook may ABORT the run by blocking — return decision=block /
# {"continue": false} or exit with code 2 — for example when a setup step
# fails. Plain stdout is surfaced to the user as additional context.
#
# Unlike most events, worktree_create is dispatched from the CLI rather than
# the run loop, because the worktree (and the working directory the runtime,
# session, tools and snapshot machinery all capture) must be settled before
# the runtime and session exist.
#
# Try it:
# cd into a git repo, then:
# docker agent run --worktree examples/worktree_create_hook.yaml
#
agents:
root:
model: openai/gpt-4o
description: Demonstrates the worktree_create hook for preparing a fresh git worktree.
instruction: |
You are a coding assistant working inside an isolated git worktree.
Use the shell and filesystem tools to help the user.
toolsets:
- type: shell
- type: filesystem
hooks:
worktree_create:
# Copy untracked dotfiles git won't bring into the new worktree.
# The worktree lives under the docker-agent data directory, far
# from the original checkout, so resolve the source via
# .worktree_source_dir rather than a relative path.
- name: seed local env
type: command
timeout: 30
command: |
INPUT=$(cat)
BRANCH=$(echo "$INPUT" | jq -r '.worktree_branch // "unknown"')
SRC=$(echo "$INPUT" | jq -r '.worktree_source_dir // ""')
if [ -n "$SRC" ] && [ -f "$SRC/.env" ] && [ ! -f .env ]; then
cp "$SRC/.env" .env
fi
echo "Prepared worktree on branch $BRANCH"
# Install dependencies in the fresh tree, aborting the run on failure.
- name: install dependencies
type: command
timeout: 600
command: |
if [ -f package.json ]; then
npm install || { echo '{"continue": false, "system_message": "npm install failed"}'; exit 2; }
fi
+21 -1
View File
@@ -1949,6 +1949,18 @@ type HooksConfig struct {
// tool_response_transform scrubs tool output. Tool-matched, like
// pre_tool_use / post_tool_use.
ToolResponseTransform []HookMatcherConfig `json:"tool_response_transform,omitempty" yaml:"tool_response_transform,omitempty"`
// WorktreeCreate hooks run once, just after `docker agent run
// --worktree` creates a git worktree and before the session starts.
// They execute inside the new worktree (their working directory is
// the fresh checkout) with the worktree path, branch, and source
// repository root passed in worktree_path / worktree_branch /
// worktree_source_dir. Use them to prepare the checkout — copy
// untracked files like .env from the source dir, install
// dependencies, warm caches. A hook may abort the run by blocking
// (decision="block" / continue=false / exit code 2); stdout is added
// as context.
WorktreeCreate []HookDefinition `json:"worktree_create,omitempty" yaml:"worktree_create,omitempty"`
}
// IsEmpty returns true if no hooks are configured
@@ -1978,7 +1990,8 @@ func (h *HooksConfig) IsEmpty() bool {
len(h.OnToolApprovalDecision) == 0 &&
len(h.BeforeCompaction) == 0 &&
len(h.AfterCompaction) == 0 &&
len(h.ToolResponseTransform) == 0
len(h.ToolResponseTransform) == 0 &&
len(h.WorktreeCreate) == 0
}
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -2238,6 +2251,13 @@ func (h *HooksConfig) Validate() error {
}
}
// Validate WorktreeCreate hooks
for i, hook := range h.WorktreeCreate {
if err := hook.validate("worktree_create", i); err != nil {
return err
}
}
return nil
}
+3 -1
View File
@@ -96,6 +96,7 @@ func compileEvents(c *Config) map[EventType][]matcher {
EventBeforeCompaction: flat(c.BeforeCompaction),
EventAfterCompaction: flat(c.AfterCompaction),
EventToolResponseTransform: compileMatchers(c.ToolResponseTransform),
EventWorktreeCreate: flat(c.WorktreeCreate),
}
}
@@ -294,7 +295,8 @@ func stdoutAsContext(event EventType) bool {
EventUserPromptSubmit,
EventTurnStart,
EventPreCompact,
EventStop:
EventStop,
EventWorktreeCreate:
return true
}
return false
+12 -1
View File
@@ -124,10 +124,21 @@ func newCommandFactory() HandlerFactory {
}
}
// hookWorkingDir resolves the directory a command hook runs in. An
// absolute override wins; a relative override is joined onto the
// executor's working directory; with no override the executor's working
// directory is used. Falling back to the executor's working directory
// (rather than "", which would inherit the process cwd) matters when the
// executor runs before the process has chdir'd into it — e.g. the
// CLI-dispatched worktree_create event, whose working dir is the freshly
// created worktree.
func hookWorkingDir(base, override string) string {
if override == "" || filepath.IsAbs(override) {
if filepath.IsAbs(override) {
return override
}
if override == "" {
return base
}
if base == "" {
return override
}
+26
View File
@@ -230,6 +230,32 @@ func TestCommandHookUsesPerHookEnvAndWorkingDir(t *testing.T) {
assert.True(t, strings.HasSuffix(result.AdditionalContext, "/scripts"), result.AdditionalContext)
}
// TestCommandHookDefaultsToExecutorWorkingDir pins that a hook WITHOUT a
// working_dir override runs in the executor's working directory rather
// than inheriting the process cwd. This matters for executors that run
// before the process has chdir'd into their working dir — e.g. the
// CLI-dispatched worktree_create event, whose working dir is the freshly
// created worktree.
func TestCommandHookDefaultsToExecutorWorkingDir(t *testing.T) {
t.Parallel()
// Resolve symlinks so the comparison is stable on macOS, where
// TempDir lives under /var -> /private/var.
workDir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
exec := NewExecutor(&Config{SessionStart: []Hook{{
Type: HookTypeCommand,
Command: `printf '{"hook_specific_output":{"additional_context":"%s"}}' "$(pwd)"`,
}}}, workDir, os.Environ())
result, err := exec.Dispatch(t.Context(), EventSessionStart, &Input{SessionID: "s"})
require.NoError(t, err)
got, err := filepath.EvalSymlinks(strings.TrimSpace(result.AdditionalContext))
require.NoError(t, err)
assert.Equal(t, workDir, got)
}
func TestHookOnErrorBlockCanDenyNonFailClosedEvent(t *testing.T) {
t.Parallel()
+26
View File
@@ -161,6 +161,22 @@ const (
// Tool-scoped: matchers select which tools the hook runs against,
// like pre_tool_use / post_tool_use.
EventToolResponseTransform EventType = "tool_response_transform"
// EventWorktreeCreate fires once, just after the CLI creates a git
// worktree for a `--worktree` run and before the session starts. The
// new working directory is reported in [Input.Cwd] (hooks run there)
// and the worktree path, branch, and source repository root are also
// carried explicitly in [Input.WorktreePath], [Input.WorktreeBranch],
// and [Input.WorktreeSourceDir]. Use it to prepare the fresh checkout
// — copy untracked files like .env from the source dir, install
// dependencies, warm caches — before the agent begins.
//
// Unlike most events it is dispatched from the CLI rather than the
// run loop, because the worktree (and the working directory every
// downstream component captures) must be settled before the runtime
// and session exist. Plain stdout is surfaced as additional context;
// a hook may abort the run by returning decision="block" (or
// continue=false / exit code 2), e.g. when setup fails.
EventWorktreeCreate EventType = "worktree_create"
)
// Input is the JSON-serializable payload passed to hooks via stdin.
@@ -293,6 +309,16 @@ type Input struct {
// applied to the session so observability handlers can audit /
// archive what was summarized.
Summary string `json:"summary,omitempty"`
// WorktreeCreate specific: the absolute path of the freshly created
// git worktree and the branch checked out in it. [Input.Cwd] is set
// to the same path so command hooks run inside the new worktree.
// WorktreeSourceDir is the repository root the worktree was branched
// from, so setup hooks can copy untracked files (.env, local config)
// from the original checkout into the fresh one.
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
WorktreeSourceDir string `json:"worktree_source_dir,omitempty"`
}
// ModelEndpoint identifies one of an agent's configured models plus
+6 -1
View File
@@ -33,6 +33,11 @@ type Worktree struct {
Branch string
// Name is the worktree's name (the part after the "worktree-" branch prefix).
Name string
// SourceDir is the root of the repository the worktree was branched
// from. The worktree lives under the data directory, far from the
// original checkout, so setup hooks need this to copy untracked files
// (.env, local config) git won't carry over.
SourceDir string
}
// Create creates a new git worktree for the repository containing dir and
@@ -69,7 +74,7 @@ func Create(ctx context.Context, dir, name string) (*Worktree, error) {
return nil, fmt.Errorf("creating git worktree: %w", err)
}
return &Worktree{Dir: dest, Branch: branch, Name: name}, nil
return &Worktree{Dir: dest, Branch: branch, Name: name, SourceDir: root}, nil
}
// validateName rejects names that would escape the worktrees directory or