Repository navigation
Conversation
PR #104226 — feat(kanban): run worktree-created hook after git worktree addWell-reasoned operator seam: linked worktrees lack gitignored secrets by definition, and the fix (a What looks right
Non-blocking points
|
ca0bac3 to
a310e56
Compare
9bada68 to
fa78dc3
Compare
|
Rebased onto What the force-push overwrites: the previous remote head One conflict, in Nothing else moved: Verified on the rebased head under the repo's hermetic runner ( GitHub now reports this PR |
A card created with `--workspace worktree` gets a linked git worktree, and a
linked worktree is a fresh checkout: tracked files and nothing else. Everything
git ignores is absent by definition, and for most real projects that includes
the `.env` the application cannot start without. The worker lands in a
repository that looks complete and fails at the first command that reads
configuration, with an error about the application rather than the checkout.
Which secrets a project needs, and where they live, is deployment knowledge no
code here can hold, so this adds a seam through the hook system that already
exists rather than a new one. `_ensure_git_worktree` fires the new
`kanban_worktree_created` plugin hook (hermes_cli/kanban_db_workspace.py)
right after `git worktree add` succeeds and before any worker spawns, with
task_id, board, profile_name, worktree_path, repo_root and branch. A plugin
subscribes with ctx.register_hook; a shell script subscribes through the
`hooks:` block in config.yaml like every other event, so `hermes hooks
list/doctor/test` already know it and consent is the bridge's existing
allowlist (TTY prompt, hooks_auto_accept, HERMES_ACCEPT_HOOKS). With no
subscriber nothing changes.
Unlike the other kanban_* hooks this one is a gate: a `{"action": "block",
"message"}` result (shell bridge: exit 2, or fail_closed on a timeout or
spawn failure) makes the caller remove the just-created worktree and raise
with the message. That raise lands in dispatch_once's workspace guard, which
records a spawn_failed and releases the claim so the card is retried; removing
the tree first is what keeps that retry honest, because a worktree left behind
would take `_ensure_git_worktree`'s idempotent early return, skip both the
`git worktree add` and the hook, and spawn the worker into the unseeded
checkout with no error attached. `git worktree remove --force` is required
(a hook that failed halfway leaves untracked files); the branch is kept. If
the removal itself fails the reason says so and names the path to remove by
hand. The retry-safety sentence is placed before the hook's text and before
git's error so `_record_task_failure`'s 500-char cut cannot drop it.
Shell hooks only reach the plugin manager through register_from_config, which
the CLI runs for agent commands (_prepare_agent_startup) and the gateway runs
at startup; `hermes kanban` is neither, so `hermes kanban claim`, a one-shot
`hermes kanban dispatch` and `hermes kanban daemon --force` fired the hook to
an empty subscriber list and handed over an unseeded tree with no warning.
kanban_ops.py gains `_register_config_hooks(args)` (the gateway's three
lines: load_config, register_from_config with the inherited --accept-hooks
flag, never raising), called in _cmd_dispatch (not on --dry-run), _cmd_daemon
after init_db and _cmd_claim before resolve_workspace. Python plugin hooks
were never affected (invoke_hook discovers plugins lazily).
Bridge changes are two entries: `kanban_worktree_created` joins
`_BLOCKING_EVENTS` in agent/shell_hooks.py (exit 2 and fail_closed now apply
to it) and gets a block-only stdout parser; `hermes hooks test/doctor` gains a
synthetic payload for the event, and doctor reports a block verdict on that
payload as such instead of the "observer-only" line it printed for any empty
stdout. Process-group kill on timeout, consent and non-TTY acceptance are the
bridge's existing behaviour, reused rather than re-implemented.
Tests: tests/hermes_cli/test_kanban_worktree_created_hook.py, 13 cases
against a real plugin manager and real `hooks:` registration with real
scripts and real `git worktree add` (no subprocess or invoke_hook mocks; the
removal-failure case stubs the module's `_git` wrapper for `worktree remove`,
the CLI cases stub the worker spawn): payload and after-checkout timing,
once-per-creation, no-subscriber no-op, block -> discard -> raise, retry
re-fires, retry verdict inside the 500-char card budget in both removal
branches, shell hook seeding from the stdin payload, exit 2 with stderr as
the reason, fail_closed timeout that also kills the hook's grandchild, and
claim / dispatch / daemon --force each seeding through a `hooks:` block in a
real config.yaml. tests/hermes_cli/test_hooks_cli.py: doctor reports the
block.
Docs: hooks.md (catalog row, kanban_worktree_created section under the
renamed "Kanban lifecycle hooks" heading, blocking-event wording for exit 2 /
fail_closed, which entry points register shell hooks), kanban.md (seeding
subsection with a config.yaml + script example, which paths wire the hooks:
block, what doctor prints for the seed script, `hermes kanban claim` caveat),
plugins.md and developer-guide/plugins/index.md hook tables.
fa78dc3 to
f811f62
Compare
|
Rebased onto current What the force-push overwrites: remote head The conflicts, all additive-adjacent:
Verification: |
What does this PR do?
A card created with
--workspace worktreegets a linked git worktree, and a linked worktree is afresh checkout: it holds tracked files and nothing else. Everything git ignores is absent by
definition — and for most real projects that includes the one file the project cannot start without.
Measured on the install this comes from: one repo keeps 780 lines across gitignored
.envand.env.dev, 33 credential-shaped keys each. The worker lands in a repository that looks complete andfails at the first command that reads configuration, with an error about the application rather
than about the checkout. It isn't even reportable as a kanban bug — that checkout is exactly what
git worktree addpromises.Which secrets a project needs, and where they are kept, is deployment knowledge that no code here can
hold. So this adds a seam, and it adds it through the hook system Hermes already has rather than a
new one: right after
git worktree addsucceeds — and before any worker is spawned —_ensure_git_worktreefires a newkanban_worktree_createdplugin hook withtask_id,board,profile_name,worktree_path,repo_rootandbranch. A Python plugin subscribes withctx.register_hook("kanban_worktree_created", ...); an operator who just wants a script subscribesthrough the existing
hooks:block inconfig.yaml:The script gets the payload as JSON on stdin (
extra.worktree_path,extra.repo_root, ...),exactly like every other shell hook. With no subscriber nothing changes.
Why this shape (review feedback on the first revision). The first revision ran a bare executable
at
$HERMES_HOME/hooks/worktree-created. That put a third, incompatible hook convention inside thedirectory the gateway
HookRegistryalready owns, invisible tohermes hooks list/doctor, and itsstated reason for not reusing the
hooks:bridge was wrong on both counts (the bridge does acceptconsent without a TTY via
--accept-hooks/HERMES_ACCEPT_HOOKS/hooks_auto_accept, and it fireson every
VALID_HOOKSevent, not just tool calls). The accurate gap was narrower: no worktree-createdevent existed, and non-tool shell-hook events cannot block. This revision closes exactly that gap:
kanban_worktree_createdjoinsVALID_HOOKS(hermes_cli/plugins.py) as the one kanban gaterather than observer. Like the other
kanban_*hooks it runs synchronously on the caller's threadand is not timeout-bounded (
_HOOK_TIMEOUT_BOUNDED_HOOKSis unchanged), so a hook that runsnpm ciis not abandoned atplugins.hook_callback_timeout; a shell hook enforces its ownper-entry
timeout(max 300s).agent/shell_hooks.pythe event joins_BLOCKING_EVENTSand gets a block-only stdout parser,so exit code 2 (stderr becomes the message) and
fail_closed: true(timeout, unstartablecommand) now produce the same
{"action": "block", "message"}apre_tool_callhook would.Everything else — allowlist consent,
split_command_linewithout a shell, own process group pluskill_process_treeon timeout,hermes hooks list/doctor/test— is reused, not re-implemented.hermes hooks test kanban_worktree_createdgains a synthetic payload with the runtime shape.Failure is atomic, as before. A block result makes the caller remove the just-created worktree
and raise with the hook's message. That raise lands in
dispatch_once's workspace guard(
hermes_cli/kanban_db_dispatch.py, theexceptaround_resolve_worktree_workspace), which recordsa
spawn_failedand releases the claim, so the card returns toreadyand is retried up tofailure_limit. Removing the tree first is what keeps that retry honest: a worktree left behindwould take
_ensure_git_worktree's idempotent early return, skip bothgit worktree addand thehook, and spawn the worker into the unseeded checkout — the exact outcome the block exists to
prevent, one tick later and with no error attached.
git worktree remove --forceis required (a hookthat failed halfway leaves untracked files, and git refuses to remove a dirty worktree); the
branch is kept (it may have existed before the call, and the retry's
worktree add <target> <branch>works on it once the tree is gone).One residual, stated rather than glossed: if the removal itself fails, the tree stays and the next
retry does spawn into it. Nothing here can prevent that, so the reason says so and names the path to
remove by hand. Both branches put the retry-safety sentence before the hook's text and before
git's error, because the reason reaches the card through
_record_task_failure, which keepserror[:500]; the two caps (300 chars of hook text, 120 of git's refusal) only bound the tail. Twotests pin that the verdict survives the 500-char cut with a 5000-char hook message in both branches.
Where the
hooks:block gets wired (review feedback on the second revision). Shell hooks reachthe plugin manager only through
agent.shell_hooks.register_from_config, which the CLI calls from_prepare_agent_startupfor agent commands (hermes,chat,acp,rl,cron run|tick,gateway run,mcp serve) and the gateway calls at startup — so the gateway-hosted dispatcher andinteractive
hermeshad the seed script, buthermes kanban claim, a one-shothermes kanban dispatchand the standalonehermes kanban daemon --forcedid not:kanbanisnot an agent command, the hook fired to an empty subscriber list, the worktree was created unseeded
with no warning, and every later tick reused it through the idempotent early return. Python plugin
callbacks were unaffected (
invoke_hookdiscovers plugins lazily); the gap was specific to thehooks:path the docs call the shortest. This revision adds_register_config_hooks(args)tohermes_cli/kanban_ops.py— the same three lines asGatewayStartupMixin._register_config_hooks:register_from_config(load_config(), accept_hooks=args.accept_hooks), never raising — and calls itin
_cmd_dispatch(skipped on--dry-run, which creates no workspace), in_cmd_daemonafterinit_db(), and in_cmd_claimright beforeresolve_workspace. Consent is unchanged: allowlist,--accept-hooks(an inherited global flag, sohermes kanban claim --accept-hooksworks),HERMES_ACCEPT_HOOKS,hooks_auto_accept, or the TTY prompt. Addingkanbanto_AGENT_SUBCOMMANDSinhermes_cli/main.pywas the alternative; it was rejected because thatmapping also drives inline MCP server discovery and plugin pre-warming, which a dispatcher process
does not need. Three tests, one per path, run the real command bodies against a real kanban DB and a
hooks:block in a realconfig.yamland fail without the change.A subtlety worth knowing as a hook author, documented in three places: only a block fails the
card.
invoke_hooklogs and skips a Python callback that raises, and the bridge treats anynon-zero exit other than 2 as a warning (its normal fail-open rule). So return the directive /
exit 2on the failing path, and set
fail_closed: trueso a hang or a missing script cannot pass anunseeded worktree either.
Deliberately not changed:
_cleanup_worktree_workspace(the completion-time pruner preservesanything dirty or unpushed, which is precisely what a half-seeded tree looks like);
_resolve_worktree_workspace's fallback paths, worktree teardown andscratch/dir:workspaces(they only gain the
task_id/boardkwargs threaded to the fire site);hermes kanban claim'sclaim handling — it now wires the
hooks:block and fires the same hook, but still has no dispatcherto release the claim, so a block there leaves the card claimed with no workspace until the claim TTL
expires. That pre-existing gap applies to every workspace failure in
_cmd_claimand is documentedin kanban.md rather than patched here. Outbound webhooks (
outbound_webhooks:) are likewise stillregistered only by the agent commands and the gateway; the new helper wires shell hooks only.
Based on
693641aa8b.Related Issue
No issue — the mechanism has been carried as a local patch against a vendored checkout and is being
upstreamed. Nothing open covers it (see the duplicate-check note below).
Fixes #
Type of Change
Changes Made
hermes_cli/kanban_db_workspace.py—_ensure_git_worktreetakes keyword-onlytask_id/board(threaded from
_resolve_worktree_workspaceand_anchored_worktree) and calls the new_fire_worktree_created_hookright aftergit worktree addsucceeds. That helper callshermes_cli.lifecycle.invoke_hook("kanban_worktree_created", ...); the first{"action": "block"}result (or an exception frominvoke_hookitself) runs_discard_unseeded_worktree(git worktree remove --forcevia the module's_githelper) andraises
RuntimeErrornaming the worktree, the removal outcome, then the hook's message._HOOK_MESSAGE_LIMIT = 300,_REMOVAL_DETAIL_LIMIT = 120.hermes_cli/plugins.py—"kanban_worktree_created"added toVALID_HOOKSwith its contract inthe adjacent comment.
agent/shell_hooks.py—_BLOCKING_EVENTSnow{"pre_tool_call", "kanban_worktree_created"};_parse_block_directive(the block half of_parse_pre_tool_call, which now calls it) registeredas the event's stdout parser.
hermes_cli/kanban_ops.py— new_register_config_hooks(args)(shell-hook registration fromconfig.yaml, mirroringGatewayStartupMixin._register_config_hooks; never raises), called from_cmd_dispatch(not on--dry-run) and_cmd_daemon(afterinit_db()).hermes_cli/kanban.py—_cmd_claimcalls_register_config_hooks(args)beforeresolve_workspace.hermes_cli/hooks.py—_DEFAULT_PAYLOADS["kanban_worktree_created"]sohermes hooks test/doctorsend the runtime payload shape;doctornow reports a block verdict on the syntheticpayload as
✓ returned a block on the synthetic payload (exit=2, …): <message>instead of theobserver-onlyline it printed for any empty stdout regardless of exit code (the documented seedscript exits 2 against doctor's
/tmp/repo, so this is the line operators will see).agent/shell_hooks.py(module docstring) — exit-2 sentence names both blocking-capable events.tests/hermes_cli/test_kanban_worktree_created_hook.py— new, 13 cases.tests/hermes_cli/test_hooks_cli.py— one newdoctorcase: an allowlisted exit-2 gate on thesynthetic payload is reported as a block, not as observer-only (fails on the previous revision).
website/docs/user-guide/features/hooks.md— catalog row, akanban_worktree_createdsectionunder the kanban hooks (heading renamed from "Kanban lifecycle observers" to "Kanban lifecycle
hooks" and the "all three are observer-only" summary now says the fourth is a gate), the exit-2 /
fail_closedprose names both blocking-capable events, and the "Shell hooks are registered by…"sentence lists the standalone kanban entry points.
website/docs/user-guide/features/kanban.md— theworktreebullet now says the dispatchercreates the worktree (it does; the old "worker-side" wording was stale) and points at a rewritten
"Seeding a worktree workspace" subsection with the
config.yamlentry, a worked script, theatomic-failure semantics, the "only a block fails the card" rule, which entry points wire the
hooks:block, whathermes hooks doctorprints for the seed script, and thehermes kanban claimcaveat.website/docs/user-guide/features/plugins.md,website/docs/developer-guide/plugins/index.md—hook tables gain the event; plugins.md drops the stale "26 lifecycle events" count (the set is 38
after this change; the sentence already defers to the canonical catalog).
No config keys, no schema changes, no new dependencies, no new files under
$HERMES_HOME.How to Test
scripts/run_tests.sh tests/hermes_cli/test_kanban_worktree_created_hook.py -q→ 13 passed.Plugin-side cases register a callback on the real plugin manager; shell-side cases go through
agent.shell_hooks.register_from_configwith a real Python script and a realgit worktree add;CLI cases run the
hermes kanban claim/dispatch/daemon --forcecommand bodies against areal kanban DB and a
hooks:block written to a realconfig.yaml(hooks_auto_accept: true).No
subprocessorinvoke_hookmocks; the two stand-ins are the removal-failure case, whichstubs the module's
_gitwrapper forworktree removeonly, and the dispatch/daemon cases,which stub
_default_spawn(thehermes chatworker) and, for the daemon, run one iteration ofthe loop body instead of
run_daemon's wait-for-signal loop. Cases: payload fields andafter-checkout timing, once-per-creation, no-subscriber no-op, block → discard → raise (with the
retry verdict ahead of the hook text), retry re-creates and re-fires, verdict inside the 500-char
card budget when the tree was removed and when
git worktree removefails, ahooks:scriptseeding the worktree from the stdin payload, exit 2 failing the card with its stderr, a
fail_closedtimeout (3 s, so two interpreter start-ups fit on a loaded runner) that fails thecard and kills the hook's grandchild (the
npm cistand-in), and the three CLI paths eachhanding over a worktree the configured script has seeded — all three fail on the previous
revision.
Regression, same runner, on the suites this touches — shell-hook bridge,
hermes hooksCLI,plugin manager, kanban lifecycle/worker hooks, and the eight worktree-touching kanban files
(
test_kanban_board_project,test_kanban_boards,test_kanban_db,test_kanban_gateway_restart_handoff,test_kanban_project_link,test_kanban_transfer,test_kanban_worktree_isolation,test_kanban_worktree_teardown) plustest_shell_hooks,test_shell_hooks_consent,test_shell_hooks_tree_kill,test_hooks_cli,test_plugins,test_kanban_lifecycle_hooks,test_kanban_worker_lifecycle_hooks, plustest_kanban_cli_dispatch_passthrough(drives_cmd_dispatch) and this PR's file: 17 files, 259 passed, 5 skipped, 0 failed.Manually, on a repo with a gitignored
.env, in the dispatcher's profile:Approve once (
hermesat a TTY, orhooks_auto_accept: true), checkhermes hooks doctor(against its synthetic
/tmp/repopayload this script exits 2, and doctor now says✓ returned a block on the synthetic payload (exit=2, …): no .env in /tmp/repo), thenhermes kanban create "touch .env" --assignee <profile> --workspace worktree.<repo>/.worktrees/<id>/.envexists — whether the card was picked up by the gateway dispatcher,by
hermes kanban dispatch, or claimed by hand withhermes kanban claim <id>. Remove the repo's.env→ the card fails withno .env in …in the reason,git worktree listshows no leftovertree, and the next dispatch tick re-creates it and fires the hook again.
Checklist
Code
I've read the Contributing Guide
My commit messages follow Conventional Commits (
fix(scope):,feat(scope):, etc.)I searched for existing PRs to make sure this isn't a duplicate
Nothing open proposes a post-
worktree addseeding hook. Searched open PRs forworktree hookandworktree-created; the nearest neighbours are all orthogonal — feat(kanban): diagnose worktree tasks with no repo to check out in #103120 (diagnose worktree taskswith no repo to check out in), fix(kanban): auto-promote scratch workspace to worktree for code-fix tasks #85087 (auto-promote scratch → worktree), fix(kanban): lease active task worktrees #95500 (lease active task
worktrees), fix(kanban): base child worktrees on parent commits #102475 / fix(kanban): stop new project tasks from starting on stale commits #86611 / fix(kanban): block conflicting worktree branches #84453 (which commit or branch a worktree starts from). None
touches what a created worktree is missing.
My PR contains only changes related to this fix/feature (no unrelated commits)
I've run
pytest tests/ -qand all tests passI've added tests for my changes (required for bug fixes, strongly encouraged for features)
I've tested on my platform: macOS 15 (Darwin 25.6), Python 3.13
Not ticking the full-suite box: what I ran is the 17-file set above through
scripts/run_tests.sh(CI parity: clean env, per-file subprocess isolation), all green.
Documentation & Housekeeping
I've updated relevant documentation (README,
docs/, docstrings) — or N/AI've updated
cli-config.yaml.exampleif I added/changed config keys — or N/A (N/A: no newconfig key; the existing
hooks:block gains an event name, documented in hooks.md and kanban.md)I've updated
CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A (N/A)I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
There is no executable-bit gate anymore (the first revision's
os.access(X_OK)degraded to anexistence check on Windows). The command runs through the bridge's
split_command_linewithshell=False, so a Windows entry iscommand: "python C:\\...\\seed.py"like any other shellhook; timeout cleanup is the bridge's
kill_process_tree(os.killpgon POSIX,taskkill /TonWindows). The removal path goes through the module's
_githelper. Tests drive Python scripts viasys.executablerather than/bin/sh;scripts/check-windows-footguns.pyis clean on the changedfiles.
I've updated tool descriptions/schemas if I changed tool behavior — or N/A (N/A: no tool schema)
Screenshots / Logs
Blocked hook, as the card's failure reason:
Shell hook that hung under
fail_closed: true(bridge warning, then the card reason):