- **Feature: new built-in developer-utility task groups** (`builtin/hash.py`, `builtin/datetime.py`, `builtin/url.py`, `builtin/json.py`, `builtin/case.py`, `builtin/cron.py`, `builtin/hex.py`, `builtin/number.py`, registered in `builtin/group.py` and `builtin/__init__.py`):
- `hash` — `hash` (text), `sum` (file, streamed in 8 KiB chunks), and `hmac`, each over `sha256`/`sha1`/`sha224`/`sha384`/`sha512`/`md5` via `hashlib.new`. Generalizes the existing md5-only group.
- `time` — `now`, `to-iso` (epoch→ISO 8601), and `to-epoch` (ISO 8601→epoch); naive ISO datetimes are interpreted as UTC.
- `url` — `encode`/`decode` (percent-encoding) and `parse` (URL split into a JSON object of scheme/host/port/path/query/fragment).
- `json` — `format`, `minify`, `validate`, `get` (dotted-path extraction, e.g. `user.roles[0]`), plus `to-yaml`/`from-yaml`.
- `case` — `convert` (snake/camel/pascal/kebab/constant/title) and `slugify` (accent-stripping, URL-friendly).
- `cron` — `parse` validates an expression and lists upcoming run times, reusing `zrb.util.cron.match_cron` (no new dependency).
- `hex` — `encode`/`decode` (ASCII↔hex, tolerating spaces and a `0x` prefix) and `dump` (offset + hex + ASCII hexdump).
- `number` — `convert` between bases 2/8/10/16.
- All new tasks are stdlib/`pyyaml`-only, so no new runtime dependency was added.
- **Feature: secure random generators** (`builtin/random.py`): added `random password`, `random token` (`secrets.token_urlsafe`), and `random string`, all backed by the `secrets` module.
- **Feature: `core-coding` skill gains a runtime-debugging / observability companion** (`llm_plugin/skills/core-coding/`):
- New `workflows/observability.md` companion for diagnosing a *running or deployed* system that can't be reproduced locally — distinct from the local-failure `workflows/debug.md` (which now cross-links it). Covers core dumps (`gdb`/`coredumpctl`, Go/Python specifics), memory & heap dumps (Java `jcmd`/MAT, Python `py-spy`/`tracemalloc`, Node heap snapshots, Go `pprof`), `kubectl` triage (exit-code reading — 137/OOMKilled, 139/SIGSEGV, CrashLoopBackOff — `--previous` logs, `kubectl debug`, `kubectl cp` to pull dumps), Grafana/Prometheus (RED/USE, PromQL for p99 / working-set / restarts), and Elasticsearch/Kibana (KQL, trace-id correlation, the `_search` API), chained metrics → logs → runtime → dump.
- Two portable, read-only Python helper tools (stdlib only): `tools/k8s-triage.py` (one-shot pod/deployment triage — status, events, `--previous` + current logs, `top`, exit-reason guidance; accepts a pod, `deploy/<name>`, or `label=value` selector) and `tools/coredump-bt.py` (all-thread `gdb` backtrace from a `<binary> <core>` pair or `--systemd <exe|pid>` via `coredumpctl`). Registered as a trigger row in `core-coding/SKILL.md` and auto-discovered as companion files.
- **Improvement: JWT decode without a secret** (`builtin/jwt.py`): `jwt decode` now defaults to inspect-without-verify (the jwt.io workflow) — paste a token and read its claims with no secret. Pass `--verify` to check the signature. Well-known timestamp claims (`exp`/`iat`/`nbf`/`auth_time`) are printed in human-readable UTC alongside their raw values.
- **Improvement: `http request` returns a pipe-friendly body** (`builtin/http.py`): the task now returns `response.text` (was a `requests.Response`, which printed as `<Response [200]>` on stdout), so `zrb http request ... | jq` works. Decorations stay on stderr. Added `body-format` (`json`/`form`/`raw`), `params` (query string as JSON), and `timeout` inputs; `HEAD`/`OPTIONS` added to the method list.
- **Improvement: Base64 URL-safe alphabet and correct validation** (`builtin/base64.py`): `encode`/`decode`/`validate` accept `--url-safe` (`-_` alphabet). `validate` now checks true base64 well-formedness with `validate=True` instead of requiring the payload to be UTF-8 text, so base64 of binary data (images, gzipped blobs) validates correctly.
- **Improvement: Claude-Code command hooks (e.g. peon-ping) now work end-to-end** (`llm/hook/hook_creators.py`, `llm/hook/hook_loader.py`, `llm/hook/manager/loader_mixin.py`, `llm/agent/run/runner.py`):
- **Event payload on stdin (ADR-0066).** `create_command_hook` now writes the Claude-shaped event JSON (`HookContext.to_claude_json()` — `hook_event_name`, `session_id`, `cwd`, `tool_name`, …) to the subprocess' stdin via `communicate(input=…)`. Stdin-driven hooks like peon-ping read `json.load(sys.stdin)["hook_event_name"]` and ignore env vars, so without this they never fired. The existing `CLAUDE_*` env vars are unchanged.
- **`settings.json` is now a hook source.** `_collect_hook_paths` also reads `.claude/settings.json` and `.claude/settings.local.json` (project and home), where Claude Code — and drop-in tools like peon-ping — register their hooks. The nested `hooks` block is parsed by the existing `_parse_claude_format`; other settings keys are ignored.
- **`Stop` fires on natural turn completion.** `run_agent` fires `HookEvent.STOP` when a turn finishes and control returns to the user, not only on manual interrupt from the TUI. This is the per-turn "done" signal completion sounds/notifications listen on. The deferred-wait path does not fire it, and manual interrupts still fire `Stop` from the TUI (no double-fire).
- Unknown events in a Claude config (`SubagentStart`/`SubagentStop`, which zrb does not yet emit) are now skipped at `debug` level instead of warning on every load.
- **`PermissionRequest` event + attention notifications** (`llm/hook/types.py`, `llm/agent/run/deferred_calls.py`, `llm/tool/ask.py`): added `HookEvent.PERMISSION_REQUEST`, fired from the approval cascade (`_resolve_approval`) exactly when a tool call reaches an interactive prompt — after every auto-resolve path (always-approve, tool/permission policy, YOLO), so it never fires for auto-approved calls. `AskUserQuestion` (auto-approved, so it skips that path) fires a `Notification` with `notification_type='elicitation_dialog'` from `ask_user_question`. Both ring peon-ping's `input.required` sound, so tool-approval and question prompts now notify you.
- **Fix: command-hook working directory.** `create_command_hook` now `expanduser`s the cwd and falls back to the inherited cwd when the directory is missing, instead of passing it straight to `create_subprocess_shell`. The UI's `Notification` hook also no longer passes the *display* cwd (`~/zrb`, home collapsed to `~`) — which the OS cannot resolve — so hooks stopped failing with `[Errno 2] No such file or directory: '~/…'` on every streamed output chunk.
- **Fix: async command hooks are truly fire-and-forget** (`llm/hook/manager/manager.py`): `execute_hooks` now dispatches `async` command hooks as detached tasks on the running event loop instead of inside the thread executor's short-lived `asyncio.run` loop (which cancelled them immediately) and then *awaiting* the subprocess. Previously every `async` peon hook — including the `Notification` hook that fires per streamed output chunk — blocked the executor up to its timeout, producing a storm of `Hook execution timed out after 10s` warnings and multi-second stalls per turn. They now return instantly and complete in the background.
- **Fix: command-hook timeout is enforced.** `create_command_hook` wraps `communicate()` in `wait_for` and `kill()`s the subprocess on timeout. The thread-pool executor's own `wait_for` cannot interrupt a blocked worker thread, so a hook (or a child it forks, e.g. peon-ping's audio player) that hung used to block well past its configured timeout.
- **Fix: hooks no longer register (and fire) twice** (`llm/hook/hook_loader.py`): `get_search_directories` now dedups by resolved path. `$HOME` is searched by both the home tier and the project upward-walk whenever cwd is under `$HOME`, so `~/.claude/*` hooks were discovered and registered twice — every event fired twice (e.g. two peon-ping toasts per prompt).
- **Fix: hook floods no longer exhaust file descriptors or storm timeouts** (`llm/hook/manager/manager.py`, `llm/hook/hook_creators.py`, `llm/ui/default/output_mixin.py`): three reinforcing fixes after fire-and-forget hooks exposed a flood. (1) Fire-and-forget command hooks are now bounded — a semaphore caps concurrent subprocesses and a backlog ceiling sheds excess — so a burst can't spawn hundreds of `peon.sh` at once (`[Errno 24] Too many open files`) or back up a serialized tool into a `timed out after 10s` storm. (2) Injected `CLAUDE_*` env values are size-capped, so a large `event_data` (e.g. full message history on `SessionStart`/`Stop`/`SessionEnd`) no longer overflows the OS exec limit (`[Errno 7] Argument list too long`); the full payload still arrives on stdin. (3) The UI no longer fires a `Notification` hook per streamed output chunk — that conflated "output produced" with the Claude `Notification` event (attention/permission), spawning a subprocess per chunk for no benefit (peon-ping suppresses it as unrecognized). Genuine attention notifications fire at the right moments (`PermissionRequest`, `elicitation_dialog`).
- **Fix: `ZRB_HOOKS_ENABLED` is honored as a global kill-switch** (`llm/hook/manager/manager.py`): the documented flag was defined but never read, so toggling it did nothing. `execute_hooks` now returns immediately — no hook fires and the filesystem is not scanned — when `CFG.HOOKS_ENABLED` is off (default stays `on`). `docs/configuration/llm-config.md` corrected (default `on`, not `1`).
- **Tests**: new coverage for every new built-in (`test_base64.py`, `test_case.py`, `test_cron.py`, `test_datetime.py`, `test_hash.py`, `test_hex.py`, `test_json.py`, `test_number.py`, `test_url.py`, plus `test_jwt.py` / `test_random.py` / `test_http.py` extensions) and for the hook changes (`test_functionality.py`: stdin payload, `settings.json` loading, async fire-and-forget non-blocking, timeout-kill, oversized-env drop, search-dir dedup; `test_deferred_calls.py` / `test_runner.py`: `PermissionRequest` / `Stop` / `Notification` firing).
- **Hook test isolation**: a session-scoped autouse fixture in `test/conftest.py` (plus `test/llm/agent/run/conftest.py`) pins the module-level `hook_manager` singleton to no search dirs and reloads it, so the suite never discovers and spawns the developer's real `~/.claude` hooks (e.g. peon-ping) — which previously made runs crawl and hang asyncio's subprocess teardown. `test/llm/hook/test_executor.py`'s timeout test was shortened (~2s → ~0.3s) since the worker thread is non-cancellable and `shutdown(wait=True)` must join it.
- **Build/test config**: `pyproject.toml` `norecursedirs` now excludes `examples/` (its scripts are demos, not part of the suite) and added the `httpx2` dev dependency; removed the redundant `examples/llm-hooks/test_hooks.py` (a never-collected demo whose scenarios are already covered by `test/llm/hook/`); `.coveragerc` omits skill tool scripts (`**/llm_plugin/skills/**/tools/*.py`), which are shipped agent-runtime content rather than unit-tested library code.