Cover the remaining role-carrying events with test_sink assertions:
session.started (ctx-held role), session.resumed and session.ended
(session-held role), turn.interrupted and turn.failed. Correct the
mcp_factory race comment (the loser fires a second plain connected and
overwrites the winner's map entry, not a reconnect) and the role_extras
doc (derived roles fall through to the session-held role).
- Register five new events (26 -> 31): rag.sync.started/completed/failed
and mcp.server.connected/failed, whitelist-gated for agent global hooks
like every non-escalation event
- Bracket every real RAG document sync with rag.sync.* via pre-resolved
RagSyncHooks snapshots carried into the build funnels; cache hits, pure
loads, and attaches fire nothing, and a user abort mid-sync counts as a
failure
- Fire mcp.server.connected only on real spawns in McpFactory::acquire
(reusing a live server is silent), marking respawns of a previously
live key with COYOTE_MCP_RECONNECT; mcp.server.failed carries
COYOTE_ERROR and COYOTE_MCP_AUTH_REQUIRED on auth failures. Boot-time
registry connects deliberately fire nothing
- Carry COYOTE_ROLE on session.* and turn.* events, resolved at fire
time so mid-session role switches and temp roles are reflected
Add a #[cfg(windows)] dispatch test module exercising the real cmd /C
raw_arg path: env delivery through a verbatim command line, fire()
detachment via a gate-file batch loop, payload-file lifecycle with the
per-user temp-dir posture assertion, backslashed COYOTE_HOOKS_DIR
install override, per-scope cwd resolution with an end-to-end %CD%
check, and exec-bit-free install. Hoist wait_for to shared test scope
and move the two OS-agnostic dispatch tests (spawn-failure payload
cleanup, debug-only error paths) into an ungated dispatch_portable
module so both cfg lanes share them instead of duplicating.
The unknown-entry diagnostic only inspected gate entries prefixed by the
current event's full name, so a partial glob like 'tool.st*' — which
extends no event name at all — was silently inert on every event.
Entries containing '*' now validate against the wildcard grammar
(*, *.*, *.<name>, <event>.* over a real event) and anything outside it
gets the same debug-level diagnostic; exact entries keep the existing
event-prefix path unchanged.
Whitelist entries now admit hooks via <event>.* (every hook of that
event), *.<name> (that hook name on every event), and * / *.*
(everything), alongside the unchanged exact <event>.<name> form.
Partial globs like tool.st* are not wildcards: they admit nothing and
still land in the unknown-entry debug diagnostics, while <event>.*
entries are exempt from them. Escalation events keep resolving ungated
regardless of whitelist content.
- strip interior NUL bytes from hook env values in fire_resolved so
agent-authored text (e.g. escalation questions) cannot suppress a hook
via a failed spawn; raise the spawn-failure log to warn!
- make Supervisor::has_active_tasks recurse into child supervisors,
mirroring cancel_recursive, so a prompt ctrl-c that cancels only a
grandchild still latches as an interruption
- document the signal-first vs error-first classification asymmetry at
top_level_agent_finished / SpawnResultHooks::fire and the deliberate
REPL teardown cancel; pin ctrlc-with-no-error => agent.interrupted
- probe Scenario J: pty-driven ctrl-c during a live headless --agent run
fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR
- escalation.raised fires in the child ctx after queue submit; escalation.answered
fires in the parent ctx from the taken request, so both agree on origin envs
- escalation.* resolves global hooks ungated: the per-agent global_hooks whitelist
never applies to these root-level events
- session.resumed fires on the use_session resume leg, inverse of session.started
- agent.interrupted replaces agent.completed/failed when the abort signal was
ctrl-c'd: top-level bracket (now abort-aware), spawned-subagent result arm
(agent__cancel / REPL ctrl-c), and graph agent nodes via the session signal;
exit_agent and the shell-exit path stay non-interrupted by passing no signal
- document the deliberate non-fire paths (direct prompts, escalation timeout and
parent-drop, graph approval nodes)
Two conformance gaps were tests-only:
- install_builtin_hooks warning: a #[serial] test proves the canonicalized
compare stays quiet when COYOTE_HOOKS_DIR merely respells the default
(via a '..' hop and, on unix, a symlink), and a second test proves a
genuine override still warns on a fresh install where the default hooks
dir does not exist (fallback compare).
- run_hook payload cleanup: a dispatch test fires a hook with a payload
and an unspawnable cwd, anchors on the spawn-failure debug log (and the
absence of a payload-write failure) so the assertion cannot pass
vacuously, then polls with a bounded deadline until the payload file is
unlinked.
Both tests were verified red-capable: reverting the canonicalization or
skipping the unlink on the error branch makes them fail.
- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
.builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts
hooks is now a reserved bundle name.
- Thread an explicit InstallMode (Skip/Prompt/Force) through the hook
installers: startup install_builtins() is pinned to Skip and never
prompts (test-pinned on both mocked-TTY and TTY-less paths);
--install-builtins hooks runs Prompt mode across all three hook
locations (global hooks dir, roles/hooks, agents/<name>/hooks) under
one shared keep-all/replace-all sticky scope; force paths keep
unconditional overwrite semantics.
- Hoist the bundle resolve_conflict/StickyMode/ConflictAction prompt
into shared config::conflict with a NonInteractive policy: bundle
installs keep their non-TTY bail, builtin hook refreshes resolve
non-TTY conflicts to keep. Bundle UX preserved byte-for-byte,
pinned by tests.
- Extend .builtin-manifest reconcile to the resolved top-level hooks
dir (shipped set from HookAssets::iter()); malformed-manifest
fail-safe (zero deletions) preserved and test-pinned.
- Sweep removed builtin agents' hooks: agent dirs absent from the
embed with a hooks/.builtin-manifest reconcile against an empty
shipped set; empty hooks dirs are removed, user files and
manifest-less agent dirs are never touched.
- Update usage-probe-hooks.sh Scenario C to the new non-interactive
keep contract and add black-box Scenarios E-G covering role/agent
hook reconcile, top-level manifest fail-safety, and the
removed-agent sweep.
- unify hook base-env assembly on hooks::base_envs_parts (drop the
hand-copied hook_base_envs in function/agents.rs; graph executor and
spawn-result hooks now share the single construction site, test-pinned)
- factor the duplicated job.started dispatch in jobs.rs handle_start
into one fire_job_started helper
- COYOTE_LLM_ERROR now carries the immediate error (to_string()),
matching COYOTE_TOOL_ERROR; test pins the new format
- remove all stale #[allow(dead_code)] markers in src/hooks.rs
- test infra: rename install_warn_collector -> install_log_collector,
add poison-symmetric warn_snapshot/debug_snapshot helpers, hoist a
shared EnvVarGuard (TestConfigDirGuard now builds on it), add a
same-fire payload-uniqueness test, and give global_tools env
overrides the same malformed-JSON debug diagnostic as global_hooks
- inspection-only flags (--info et al) bypass the first-run wizard:
missing config + info_flag returns Config::default() without writing
anything, in both the sandbox and interactive arms (tests included)
- bound the shim-relocation test's exec in an ETXTBSY retry (10x50ms)
- document the intended 'roles' reserved-name asymmetry at
resolve_bundle_name's gate
- AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving
the 'hooks' bundle name via the existing AssetCategory::parse check
- HookAssets embed + install_builtin_hooks(force): installs the bundled
example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired
into install_builtins() and install_assets(); warns once when
COYOTE_HOOKS_DIR diverges from the config-dir default
- assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback,
with AppleScript-literal sanitization of model-influenced values
- assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets
excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}
- sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and
roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd)
- tests: install+0755+force, install_assets(Hooks) end-to-end, discover()
roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script
content order + real execution (secret exclusion, tty-less fallback),
CLI parser round-trips; hooks_dir env test made #[serial]
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.
Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).
Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).
Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.
Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
Wire the hook engine into the three runtime seams:
- tool.*: ToolCall::eval fires tool.started once the name and arguments
are resolved, then exactly one of tool.completed/tool.failed,
discriminated on both the Err path and tool_call_error-shaped outputs.
The arguments JSON is handed to the engine as the payload; eval itself
performs no payload-file I/O.
- llm.request.*: the three transport free functions
(call_chat_completions, call_chat_completions_streaming, and the quiet
graph-node variant) fire started/completed/failed for every provider
and caller, classifying failures as aborted, api_error (with
COYOTE_LLM_STATUS), or other.
- job.*: handle_job_tool fires job.started before either spawn and the
terminal event inside the detached closures via a pre-resolved
fire_resolved snapshot, so job__start's return is never delayed.
The test sink now captures the payload and cwd of each dispatch
(sink-only change), and seam tests pin event cadence, env contracts, and
byte-identical tool results and errors.
Introduce src/hooks.rs as a types-only module (HookDef, HooksMap) and
plumb hooks:/global_hooks: config fields through every scope:
- Config + AppConfig hooks field, both Default impls, the manual
from_config copy, and a COYOTE_HOOKS env override
- Role frontmatter: hand-parsed hooks arm, private field, getter, and
JSON-inline re-serialization in Role::export (malformed maps degrade
to None)
- AgentConfig + Graph: hooks + global_hooks on both structs, copied in
AgentConfig::from_graph, with a per-agent global_hooks env override
- paths::hooks_dir() = <config_dir>/hooks with COYOTE_HOOKS_DIR override
- Commented hooks: block in the global config template (global scope
only; no global_hooks key)
No engine or dispatch logic yet; existing configs parse unchanged.