diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c364b4..0916342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: bash tests/handoff_agent_test.sh bash tests/review_on_demand_test.sh bash tests/herdr_bin_fallback_test.sh + bash tests/notify_test.sh test: needs: rust-plugin diff --git a/README.md b/README.md index b98ef63..5f852c2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Bercail's workflow is designed around Shep. Shep owns the workflow; bercail runs 1. Shep writes the job as a beads issue (`bd create`): the ask, the repo, links, and any "plan only" note. 2. Shep picks a stage for that issue and runs `handoff-spawn --stage start|codex|cursor --issue ID` (see [handoff](#handoff)). bercail never picks the stage and never falls back to another one. 3. bercail checks `bd` and the issue, adds a `bd comment` with the worktree (and, headless, the result file), and starts that one harness with the issue id as its whole prompt. -4. Shep reads `.bercail/handoff-result.json` (`ready`, `blocked`, or `failed`), resumes the session if needed, and tells you when the desk is ready. +4. For a `--branch` pane, Shep hears `finished` and `blocked` from its [agent status webhook](#agent-status-webhooks). A headless run has no Herdr pane and sends no webhook: `handoff-spawn` returns when it ends. Either way, Shep reads `.bercail/handoff-result.json` (`ready`, `blocked`, or `failed`), resumes the session if needed, and tells you when the desk is ready. Shep runs the same workflow whichever harness works the issue. @@ -181,6 +181,38 @@ Manual skill install: `npx skills add simoncrypta/bercail --skill handoff -g` Review launch: own work → `tuicr -w` (uncommitted only, watching; agents stage and never commit unreviewed work, and a clean tree does not auto-open); someone else's PR on this checkout → `tuicr pr `. Pickr defaults to `tuicr pr {url}`. `auto_review = false` disables the agent-done hook. A live Review pane is not restarted — tuicr's diff watch keeps it current. +### agent status webhooks + +The layout plugin's `notify.sh` hook forwards agent status to webhooks, one sink per bot. Herdr's `pane.agent_status_changed` carries no previous status, and it also fires for title-only changes and for `done` → `idle` when you look at a pane. `notify.sh` keeps each pane's last live status (checked with `herdr pane get`) and sends only these: + +- `finished`: working or blocked → done or idle +- `blocked`: once each time the agent blocks + +Shell, review, and sidebar panes are skipped. + +Sinks are `~/.config/bercail/notify.d/.toml` (`chmod 600`; see [`notify.sink.example`](plugins/agentic-layout/notify.sink.example)). They filter by workspace label glob, repo, and kind. The key is sent through a private header file, never through argv. A 401 or 403 shows a Herdr notification once per outage and logs every time. Without sinks the hook does nothing. + +```bash +plugin=$(herdr plugin list --plugin agentic-dev.layout --json | jq -r '.result.plugins[0].plugin_root') +bash "$plugin/notify.sh" status # sinks, filters, last HTTP code; never prints url or key +bash "$plugin/notify.sh" test work-shep +``` + +Each POST body is `bercail.agent_status/v1`: + +```json +{"source":"bercail","schema":"bercail.agent_status/v1","kind":"finished","sink":"work-shep", + "at":"2026-10-22T22:42:00Z","event_id":"w6N:p2:finished:1792708920", + "pane":{"pane_id":"w6N:p2","workspace_id":"w6N","agent":"claude","role":"agent", + "status":"done","previous_status":"working","verified":true}, + "workspace":{"label":"Render-preview_Leftlane_app","repo":"leftlane_app","checkout_path":"/path/desk","linked":true}, + "desk":{"branch":"feat/render","issue":"lef2745-2vs","stage":"start","harness":"claude", + "result":{"status":"ready","summary":"...","session_id":"..."}}, + "event":{"event":"pane.agent_status_changed","data":{}},"context":{}} +``` + +`desk.issue`, `stage`, and `harness` come from the matching `handoffs.jsonl` record. `desk.result` is the checkout's `.bercail/handoff-result.json` when one exists (usually `null`: headless runs have no pane, so they send no events). `event` and `context` are Herdr's raw hook payloads. + ## keys Prefix is **`Ctrl-Space`**. Linux: [`config/herdr/config.toml`](config/herdr/config.toml) (Alt). macOS: [`config/herdr/config.macos.toml`](config/herdr/config.macos.toml) (Option). diff --git a/plugins/agentic-layout/ARCHITECTURE.md b/plugins/agentic-layout/ARCHITECTURE.md index 09b5e55..3b3302c 100644 --- a/plugins/agentic-layout/ARCHITECTURE.md +++ b/plugins/agentic-layout/ARCHITECTURE.md @@ -1,10 +1,11 @@ # Architecture -The plugin is three Bash modules: +The plugin is four Bash modules: - `state.sh` — reentrant layout lock, probe, load, save, v3→v4 migrate (drops `main_tab_id`). Every state write goes through the lock. - `topology.sh` — Shell tab always, Review on demand, four panes, dock-then-focus tab switch - `layout.sh` — config, Herdr helpers, actions, events +- `notify.sh` — agent-status fan-out to `~/.config/bercail/notify.d` webhook sinks (own hook; per-pane prev status under `/notify`, swapped under a lock; delivery runs after the lock is released so a slow sink never delays other panes; `pane.exited`/`workspace.closed` forget saved statuses so reused pane ids start fresh) ## Pane topology diff --git a/plugins/agentic-layout/herdr-plugin.toml b/plugins/agentic-layout/herdr-plugin.toml index 49e9f87..5481be3 100644 --- a/plugins/agentic-layout/herdr-plugin.toml +++ b/plugins/agentic-layout/herdr-plugin.toml @@ -189,3 +189,16 @@ command = ["bash", "layout.sh", "event-tab-focused"] [[events]] on = "pane.agent_status_changed" command = ["bash", "layout.sh", "event-agent-status"] + +# Fan-out to webhook sinks in ~/.config/bercail/notify.d (no-op without sinks). +[[events]] +on = "pane.agent_status_changed" +command = ["bash", "notify.sh", "event"] + +[[events]] +on = "pane.exited" +command = ["bash", "notify.sh", "pane-exited"] + +[[events]] +on = "workspace.closed" +command = ["bash", "notify.sh", "workspace-closed"] diff --git a/plugins/agentic-layout/notify.sh b/plugins/agentic-layout/notify.sh new file mode 100755 index 0000000..908de02 --- /dev/null +++ b/plugins/agentic-layout/notify.sh @@ -0,0 +1,524 @@ +#!/usr/bin/env bash +# Fan out Herdr agent status changes to named webhook sinks. +# +# One event hook (pane.agent_status_changed) serves every bot. Each sink is a +# file in ~/.config/bercail/notify.d/.toml with its own URL, key, and +# filters. The hook is stateful: it keeps each pane's last live status, so it +# can tell "finished" (working -> done/idle) from "already idle", and report +# "blocked" once per episode. Herdr's own payload has no previous status. +# +# notify.sh event Herdr event hook (reads HERDR_PLUGIN_EVENT_JSON) +# notify.sh pane-exited forget a pane's saved status (also workspace-closed) +# notify.sh status sinks, filters, and last delivery result (no secrets) +# notify.sh test NAME send one kind=test payload to sink NAME +# shellcheck disable=SC1091 +set -euo pipefail + +if [[ -n "${HERDR_BIN_PATH:-}" && ! -x "$HERDR_BIN_PATH" ]]; then + unset HERDR_BIN_PATH +fi +HERDR="${HERDR_BIN_PATH:-herdr}" +NOTIFY_SCHEMA="bercail.agent_status/v1" + +_notify_plugin_root() { + if [[ -n "${HERDR_PLUGIN_ROOT:-}" ]]; then + printf '%s' "$HERDR_PLUGIN_ROOT" + else + cd "$(dirname "${BASH_SOURCE[0]}")" && pwd + fi +} + +# shellcheck source=state.sh +source "$(_notify_plugin_root)/state.sh" + +notify_sinks_dir() { + printf '%s' "${BERCAIL_NOTIFY_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/bercail/notify.d}" +} + +notify_state_dir() { + local dir + dir="$(_state_dir)/notify" + mkdir -p "$dir/panes" "$dir/sinks" + printf '%s' "$dir" +} + +notify_handoffs_file() { + printf '%s/agentic-dev/handoffs.jsonl' "${XDG_STATE_HOME:-$HOME/.local/state}" +} + +_notify_err() { + printf 'bercail notify: %s\n' "$*" >&2 +} + +_notify_now() { + date +%s +} + +notify_sink_files() { + local dir f + dir="$(notify_sinks_dir)" + [[ -d "$dir" ]] || return 0 + for f in "$dir"/*.toml; do + [[ -f "$f" ]] && printf '%s\n' "$f" + done +} + +notify_sink_name() { + local base + base="$(basename "$1")" + printf '%s' "${base%.toml}" +} + +# Flat `key = value` reader (same subset as config-reader.sh). Quotes optional. +notify_sink_value() { + local file="$1" key="$2" default="${3:-}" line val + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ "$line" == \#* ]] && continue + [[ "$line" =~ ^${key}[[:space:]]*= ]] || continue + val="${line#*=}" + val="${val#"${val%%[![:space:]]*}"}" + if [[ "$val" == \"* ]]; then + val="${val#\"}" + val="${val%%\"*}" + else + val="${val%%#*}" + val="${val%"${val##*[![:space:]]}"}" + fi + printf '%s' "$val" + return 0 + done <"$file" + printf '%s' "$default" +} + +_notify_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '600' +} + +# A file holding a key must not be group/other readable. +notify_private_ok() { + local mode + mode="$(_notify_mode "$1")" + [[ "${mode: -2}" == "00" ]] +} + +notify_sink_key() { + local file="$1" key key_file + key="$(notify_sink_value "$file" key "")" + if [[ -n "$key" ]]; then + printf '%s' "$key" + return 0 + fi + key_file="$(notify_sink_value "$file" key_file "")" + key_file="${key_file/#\~/$HOME}" + [[ -n "$key_file" && -r "$key_file" ]] || return 0 + notify_private_ok "$key_file" || return 2 + tr -d '\r\n' <"$key_file" +} + +notify_bool() { + case "$1" in + 0|false|False|FALSE|no|off) return 1 ;; + *) return 0 ;; + esac +} + +# Comma-separated list contains WORD (case-insensitive, trimmed). +notify_list_has() { + local list="$1" want="$2" item + local IFS=',' + for item in $list; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [[ "${item,,}" == "${want,,}" ]] && return 0 + done + return 1 +} + +# Comma-separated glob list matches VALUE (case-insensitive). +notify_glob_match() { + local list="$1" value="$2" item + local IFS=',' + for item in $list; do + item="${item#"${item%%[![:space:]]*}"}" + item="${item%"${item##*[![:space:]]}"}" + [[ -n "$item" ]] || continue + # shellcheck disable=SC2053 + [[ "${value,,}" == ${item,,} ]] && return 0 + done + return 1 +} + +# previous live status -> current live status => finished | blocked | none. +# Herdr reports `done` for an unseen idle agent and `idle` once seen, so +# done -> idle (Simon looked at the pane) is not a second finish. +notify_classify() { + local prev="$1" cur="$2" + [[ "$prev" == "$cur" ]] && { printf 'none'; return 0; } + case "$cur" in + blocked) printf 'blocked' ;; + done|idle) + case "$prev" in + working|blocked) printf 'finished' ;; + *) printf 'none' ;; + esac + ;; + *) printf 'none' ;; + esac +} + +_notify_pane_file() { + local pane="${1//[^A-Za-z0-9_.-]/_}" + printf '%s/panes/%s.json' "$(notify_state_dir)" "$pane" +} + +notify_pane_prev() { + local f + f="$(_notify_pane_file "$1")" + [[ -r "$f" ]] || return 0 + jq -r '.status // empty' "$f" 2>/dev/null || true +} + +notify_pane_save() { + local pane="$1" status="$2" f tmp + f="$(_notify_pane_file "$pane")" + tmp="$f.$$" + jq -nc --arg pane "$pane" --arg status "$status" --argjson at "$(_notify_now)" \ + '{pane_id:$pane,status:$status,at:$at}' >"$tmp" && mv -f "$tmp" "$f" +} + +_notify_event_field() { + printf '%s' "${HERDR_PLUGIN_EVENT_JSON:-{\}}" \ + | jq -r --arg f "$1" '.data[$f] // .[$f] // empty' 2>/dev/null || true +} + +_notify_json_or_empty() { + local v="${1:-}" + if [[ -n "$v" ]] && jq -e . >/dev/null 2>&1 <<<"$v"; then + printf '%s' "$v" + else + printf '{}' + fi +} + +# Live pane snapshot from herdr, or {} when the pane cannot be read. +notify_live_pane() { + local out + out="$("$HERDR" pane get "$1" 2>/dev/null /dev/null || printf '{}' +} + +notify_workspace_info() { + local workspace_id="$1" context="$2" label worktree out + label="$(jq -r '.workspace_label // empty' <<<"$context")" + worktree="$(jq -c '.worktree // empty' <<<"$context")" + if [[ -z "$label" || -z "$worktree" ]] && [[ -n "$workspace_id" ]]; then + out="$("$HERDR" workspace get "$workspace_id" 2>/dev/null /dev/null || true)" + [[ -n "$worktree" ]] || worktree="$(jq -c '.result.workspace.worktree // empty' <<<"$out" 2>/dev/null || true)" + fi + [[ -n "$worktree" ]] || worktree='{}' + jq -nc --arg id "$workspace_id" --arg label "$label" \ + --arg cwd "$(jq -r '.workspace_cwd // empty' <<<"$context")" --argjson wt "$worktree" ' + {workspace_id:$id, label:$label, + repo:($wt.repo_name // null), + checkout_path:($wt.checkout_path // (if $cwd == "" then null else $cwd end)), + linked:($wt.is_linked_worktree // false)}' +} + +# Branch, beads issue, stage, harness, and headless result for a checkout. +notify_desk_info() { + local path="$1" agent="$2" branch="" handoff='{}' result='null' file + if [[ -n "$path" && -d "$path" ]]; then + branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + file="$(notify_handoffs_file)" + if [[ -r "$file" ]]; then + handoff="$(jq -c --arg p "$path" 'select(.path == $p)' "$file" 2>/dev/null | tail -n 1)" + [[ -n "$handoff" ]] || handoff='{}' + fi + if [[ -r "$path/.bercail/handoff-result.json" ]]; then + result="$(jq -c '{status, summary:((.summary // "") | .[0:500]), session_id}' \ + "$path/.bercail/handoff-result.json" 2>/dev/null || printf 'null')" + fi + fi + jq -nc --arg branch "$branch" --arg agent "$agent" --argjson h "$handoff" --argjson r "$result" ' + {branch:(if $branch == "" then ($h.branch // null) else $branch end), + issue:($h.issue // null), stage:($h.stage // null), + harness:($h.harness // (if $agent == "" then null else $agent end)), + result:$r}' +} + +# Does SINK want this KIND for this pane/workspace? Empty filters match all. +notify_sink_wants() { + local file="$1" kind="$2" pane_json="$3" ws_json="$4" + local kinds panes workspaces repos label repo agent role + notify_bool "$(notify_sink_value "$file" enabled true)" || return 1 + if [[ "$kind" != "test" ]]; then + kinds="$(notify_sink_value "$file" notify "finished, blocked")" + notify_list_has "$kinds" "$kind" || return 1 + fi + panes="$(notify_sink_value "$file" panes agent)" + if [[ "$panes" == "agent" && "$kind" != "test" ]]; then + agent="$(jq -r '.agent // empty' <<<"$pane_json")" + role="$(jq -r '.role // empty' <<<"$pane_json")" + [[ -n "$agent" || "$role" == "agent" ]] || return 1 + [[ -z "$role" || "$role" == "agent" ]] || return 1 + fi + workspaces="$(notify_sink_value "$file" workspaces "")" + repos="$(notify_sink_value "$file" repos "")" + [[ -z "$workspaces" && -z "$repos" ]] && return 0 + [[ "$kind" == "test" ]] && return 0 + label="$(jq -r '.label // empty' <<<"$ws_json")" + repo="$(jq -r '.repo // empty' <<<"$ws_json")" + [[ -n "$workspaces" && -n "$label" ]] && notify_glob_match "$workspaces" "$label" && return 0 + [[ -n "$repos" && -n "$repo" ]] && notify_glob_match "$repos" "$repo" && return 0 + return 1 +} + +_notify_sink_state_file() { + printf '%s/sinks/%s.json' "$(notify_state_dir)" "$1" +} + +# Record a delivery. 401/403 raise one Herdr toast per failure episode and +# always go to stderr (the plugin log), so a dead key is never silent. +notify_record() { + local name="$1" code="$2" f prev='{}' prev_auth auth="false" ok="false" now tmp + f="$(_notify_sink_state_file "$name")" + # A corrupt or empty state file must not stop recording. + [[ -r "$f" ]] && prev="$(jq -c 'objects' "$f" 2>/dev/null | head -n 1)" + [[ -n "$prev" ]] || prev='{}' + prev_auth="$(jq -r '.auth_failed // false' <<<"$prev")" + now="$(_notify_now)" + case "$code" in + 2??) ok="true" ;; + 401|403) auth="true" ;; + esac + tmp="$f.$$" + jq -nc --arg name "$name" --arg code "$code" --argjson now "$now" \ + --argjson ok "$ok" --argjson auth "$auth" \ + --argjson prev "$prev" ' + $prev + {sink:$name, last_code:$code, last_at:$now, auth_failed:$auth} + + (if $ok then {last_ok_at:$now, failures:0} + else {last_error_at:$now, failures:(($prev.failures // 0) + 1)} end)' >"$tmp" \ + && mv -f "$tmp" "$f" || rm -f "$tmp" + if [[ "$auth" == "true" ]]; then + _notify_err "sink '$name' rejected its key (HTTP $code); fix the key in $(notify_sinks_dir)/$name.toml" + if [[ "$prev_auth" != "true" ]]; then + "$HERDR" notification show "bercail notify: $name key rejected" \ + --body "HTTP $code from sink '$name'. Deliveries fail until the key is fixed." \ + --sound request >/dev/null 2>&1 /dev/null 2>&1 "$hdr" + for attempt in 1 2; do + code="$(printf '%s' "$body" | "${NOTIFY_CURL:-curl}" -sS -m "$timeout" -o /dev/null \ + -w '%{http_code}' -X POST -H "@$hdr" --data-binary @- "$url" 2>/dev/null || true)" + [[ -n "$code" ]] || code="000" + case "$code" in + 000|5??) [[ "$attempt" -eq 1 ]] && sleep "${NOTIFY_RETRY_SLEEP:-1}" && continue ;; + esac + break + done + rm -f "$hdr" + printf '%s' "$code" +} + +notify_payload() { + local kind="$1" sink="$2" pane_json="$3" ws_json="$4" desk_json="$5" event_id="$6" + local event context + event="$(_notify_json_or_empty "${HERDR_PLUGIN_EVENT_JSON:-}")" + context="$(_notify_json_or_empty "${HERDR_PLUGIN_CONTEXT_JSON:-}")" + jq -nc --arg schema "$NOTIFY_SCHEMA" --arg kind "$kind" --arg sink "$sink" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg event_id "$event_id" \ + --argjson pane "$pane_json" --argjson ws "$ws_json" --argjson desk "$desk_json" \ + --argjson event "$event" --argjson context "$context" ' + {source:"bercail", schema:$schema, kind:$kind, sink:$sink, at:$at, + event_id:$event_id, + pane:$pane, workspace:$ws, desk:$desk, event:$event, context:$context}' +} + +notify_deliver_all() { + local kind="$1" pane_json="$2" ws_json="$3" desk_json="$4" event_id="$5" f name body code + while IFS= read -r f; do + [[ -n "$f" ]] || continue + name="$(notify_sink_name "$f")" + notify_sink_wants "$f" "$kind" "$pane_json" "$ws_json" || continue + body="$(notify_payload "$kind" "$name" "$pane_json" "$ws_json" "$desk_json" "$event_id")" + code="$(notify_post "$f" "$body")" + case "$code" in + nourl) _notify_err "sink '$name' has no url; skipped" ;; + insecure) + _notify_err "sink '$name' key file is readable by others; chmod 600 it. Skipped." + "$HERDR" notification show "bercail notify: $name skipped" \ + --body "Key file is group/other readable; chmod 600." >/dev/null 2>&1 /dev/null || true)" ]] && printf set || printf none)" \ + "$(notify_sink_value "$f" notify "finished, blocked")" \ + "$(notify_sink_value "$f" panes agent)" \ + "$(notify_sink_value "$f" workspaces "")" "$(notify_sink_value "$f" repos "")" + if [[ -r "$st" ]]; then + jq -r '" last=\(.last_code) auth_failed=\(.auth_failed) failures=\(.failures // 0) last_ok_at=\(.last_ok_at // "never")"' "$st" + else + printf ' no deliveries yet\n' + fi + done < <(notify_sink_files) + [[ "$found" -eq 1 ]] || printf '(no sinks)\n' +} + +notify_test() { + local name="${1:-}" f code pane ws desk + [[ -n "$name" ]] || { _notify_err "usage: notify.sh test SINK"; return 1; } + f="$(notify_sinks_dir)/$name.toml" + [[ -f "$f" ]] || { _notify_err "no sink $f"; return 1; } + pane='{"pane_id":null,"status":"test","previous_status":null,"verified":false}' + ws='{"label":null,"repo":null}' + desk='{"branch":null,"issue":null,"stage":null,"harness":null,"result":null}' + code="$(notify_post "$f" "$(notify_payload test "$name" "$pane" "$ws" "$desk" "test:$(_notify_now)")")" + case "$code" in + nourl|insecure) _notify_err "sink '$name': $code"; return 1 ;; + esac + notify_record "$name" "$code" + printf '%s HTTP %s\n' "$name" "$code" + [[ "$code" == 2?? ]] +} + +notify_main() { + case "${1:-event}" in + event) notify_event ;; + pane-exited) notify_forget pane ;; + workspace-closed) notify_forget workspace ;; + status) notify_status ;; + test) notify_test "${2:-}" ;; + *) _notify_err "unknown command '${1:-}' (event|pane-exited|workspace-closed|status|test NAME)"; return 1 ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + notify_main "$@" +fi diff --git a/plugins/agentic-layout/notify.sink.example b/plugins/agentic-layout/notify.sink.example new file mode 100644 index 0000000..bedd672 --- /dev/null +++ b/plugins/agentic-layout/notify.sink.example @@ -0,0 +1,18 @@ +# Copy to ~/.config/bercail/notify.d/.toml, one file per bot. +# chmod 600 it if it holds `key`; notify.sh refuses group/other-readable keys. +# Flat `key = value` only. Lists are comma separated. Quotes are optional. + +url = "https://example.invalid/your/webhook" +key = "paste-the-automation-key" # or: key_file = ~/.config/bercail/work-shep.key (chmod 600) +auth = both # both | bearer | x-automation-key | none + +# Which workspaces. Empty workspaces and repos means every workspace. +# workspaces: Herdr workspace label globs, case-insensitive. +# repos: worktree repo_name globs (handoff desks keep the main repo's name). +workspaces = "Work, *_Leftlane_app" +repos = leftlane_app + +panes = agent # agent (default) | any +notify = finished, blocked # kinds to send +enabled = true +timeout = 8 # seconds per POST (one retry on 000/5xx) diff --git a/plugins/agentic-layout/tests/notify_test.sh b/plugins/agentic-layout/tests/notify_test.sh new file mode 100644 index 0000000..150bdf0 --- /dev/null +++ b/plugins/agentic-layout/tests/notify_test.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# notify.sh: classification, filters, enrichment, delivery, auth alerts. +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "$TEST_DIR/.." && pwd)" +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +export HERDR_CALL_LOG="$TMP_DIR/herdr-calls.log" +export PANES_DIR="$TMP_DIR/panes" +export CURL_DIR="$TMP_DIR/curl-log" +mkdir -p "$PANES_DIR" "$CURL_DIR" + +# Fake herdr: `pane get ID` prints $PANES_DIR/ID.json (missing => error). +cat >"$TMP_DIR/herdr" <<'FAKE_HERDR' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$HERDR_CALL_LOG" +case "$1 $2" in + "pane get") + [[ -f "$PANES_DIR/$3.json" ]] || { echo '{"error":{"code":"pane_not_found"}}'; exit 1; } + printf '{"result":{"pane":%s}}\n' "$(cat "$PANES_DIR/$3.json")" + ;; + "workspace get") + printf '%s\n' '{"result":{"workspace":{"workspace_id":"w9","label":"Fallback","worktree":{"repo_name":"other"}}}}' + ;; + *) printf '%s\n' '{"result":{}}' ;; +esac +FAKE_HERDR +chmod +x "$TMP_DIR/herdr" + +# Fake curl: log argv, header file contents, body; reply with $CURL_DIR/code. +cat >"$TMP_DIR/curl" <<'FAKE_CURL' +#!/usr/bin/env bash +n=$(( $(cat "$CURL_DIR/count" 2>/dev/null || echo 0) + 1 )) +echo "$n" >"$CURL_DIR/count" +printf '%s\n' "$*" >>"$CURL_DIR/argv.log" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -H) [[ "$2" == @* ]] && cat "${2#@}" >"$CURL_DIR/headers.$n"; shift 2 ;; + -m|-o|-w|-X|--data-binary) shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +cat >"$CURL_DIR/body.$n" +printf '%s\n' "$url" >"$CURL_DIR/url.$n" +code="$(cat "$CURL_DIR/code" 2>/dev/null || echo 200)" +[[ -f "$CURL_DIR/slow.${url##*/}" ]] && sleep "$(cat "$CURL_DIR/slow.${url##*/}")" +printf '%s' "$code" +FAKE_CURL +chmod +x "$TMP_DIR/curl" + +export HERDR_BIN_PATH="$TMP_DIR/herdr" +export NOTIFY_CURL="$TMP_DIR/curl" +export NOTIFY_RETRY_SLEEP=0 +export HERDR_PLUGIN_ROOT="$PLUGIN_ROOT" +export HOME="$TMP_DIR/home" +export XDG_STATE_HOME="$TMP_DIR/state-home" +export XDG_CONFIG_HOME="$TMP_DIR/config" +export HERDR_PLUGIN_STATE_DIR="$TMP_DIR/plugin-state" +SINKS="$XDG_CONFIG_HOME/bercail/notify.d" +mkdir -p "$HOME" "$SINKS" + +# shellcheck disable=SC1091 +source "$PLUGIN_ROOT/notify.sh" + +# --- classification ------------------------------------------------------ +[[ "$(notify_classify working "done")" == finished ]] || fail "working->done" +[[ "$(notify_classify working idle)" == finished ]] || fail "working->idle" +[[ "$(notify_classify blocked idle)" == finished ]] || fail "blocked->idle" +[[ "$(notify_classify "done" idle)" == none ]] || fail "done->idle is a seen-flip" +[[ "$(notify_classify '' idle)" == none ]] || fail "unknown prev idle" +[[ "$(notify_classify '' "done")" == none ]] || fail "first sight done" +[[ "$(notify_classify working blocked)" == blocked ]] || fail "working->blocked" +[[ "$(notify_classify blocked blocked)" == none ]] || fail "blocked twice" +[[ "$(notify_classify idle working)" == none ]] || fail "working" +[[ "$(notify_classify "done" "done")" == none ]] || fail "duplicate done" + +notify_glob_match "Work, *_Leftlane_app" "Render-preview_leftlane_APP" || fail "glob ci" +! notify_glob_match "Work" "Workbench" || fail "glob must anchor" +notify_list_has "finished, blocked" Blocked || fail "list_has" + +# --- fixtures ------------------------------------------------------------ +DESK="$TMP_DIR/desk" +git init -q -b feat/render "$DESK" +mkdir -p "$DESK/.bercail" "$XDG_STATE_HOME/agentic-dev" +printf '%s\n' '{"status":"ready","summary":"Rendered preview","session_id":"s1"}' >"$DESK/.bercail/handoff-result.json" +printf '%s\n' \ + "{\"ok\":true,\"path\":\"$DESK\",\"branch\":\"old\",\"issue\":\"lef-old\"}" \ + "{\"ok\":true,\"path\":\"$DESK\",\"branch\":\"feat/render\",\"issue\":\"lef2745-2vs\",\"stage\":\"start\",\"harness\":\"claude\"}" \ + >"$XDG_STATE_HOME/agentic-dev/handoffs.jsonl" + +set_pane() { # id status agent role ws + local agent='null' tokens='{}' + [[ -n "$3" ]] && agent="\"$3\"" + [[ -n "$4" ]] && tokens="{\"agentic_role\":\"$4\"}" + printf '{"pane_id":"%s","workspace_id":"%s","agent_status":"%s","agent":%s,"tokens":%s}\n' \ + "$1" "${5:-w6N}" "$2" "$agent" "$tokens" >"$PANES_DIR/$1.json" +} + +fire() { # pane event_status [label repo] + export HERDR_PLUGIN_EVENT_JSON="{\"event\":\"pane.agent_status_changed\",\"data\":{\"pane_id\":\"$1\",\"workspace_id\":\"w6N\",\"agent_status\":\"$2\",\"agent\":\"claude\",\"title\":\"t\"}}" + export HERDR_PLUGIN_CONTEXT_JSON="{\"workspace_id\":\"w6N\",\"workspace_label\":\"${3:-Render-preview_Leftlane_app}\",\"workspace_cwd\":\"$DESK\",\"worktree\":{\"repo_name\":\"${4:-leftlane_app}\",\"checkout_path\":\"$DESK\",\"is_linked_worktree\":true}}" + bash "$PLUGIN_ROOT/notify.sh" event 2>>"$TMP_DIR/stderr.log" +} + +posts() { cat "$CURL_DIR/count" 2>/dev/null || echo 0; } +last_body() { cat "$CURL_DIR/body.$(posts)"; } + +# --- no sinks: no herdr calls at all --------------------------------------- +set_pane p2 working claude agent +fire p2 working +[[ ! -s "$HERDR_CALL_LOG" ]] || fail "no sinks must not call herdr" + +# --- sinks ----------------------------------------------------------------- +SECRET="sekret-$$-value" +cat >"$SINKS/work-shep.toml" <"$TMP_DIR/perso.key" +chmod 600 "$TMP_DIR/perso.key" +cat >"$SINKS/perso.toml" <"$SINKS/off.toml" + +set_pane p2 working claude agent +fire p2 working +[[ "$(posts)" == 0 ]] || fail "working must not post" + +set_pane p2 "done" claude agent +fire p2 "done" +[[ "$(posts)" == 1 ]] || fail "working->done should post once to work-shep only, got $(posts)" +grep -q 'hook/work' "$CURL_DIR/url.1" || fail "wrong sink url" +body="$(last_body)" +jq -e '.kind == "finished" and .schema == "bercail.agent_status/v1" and .sink == "work-shep"' <<<"$body" >/dev/null || fail "envelope: $body" +jq -e '.pane.status == "done" and .pane.previous_status == "working" and .pane.verified == true and .pane.role == "agent"' <<<"$body" >/dev/null || fail "pane: $body" +jq -e '.workspace.repo == "leftlane_app" and .workspace.linked == true' <<<"$body" >/dev/null || fail "workspace: $body" +jq -e '.desk.branch == "feat/render" and .desk.issue == "lef2745-2vs" and .desk.stage == "start" and .desk.harness == "claude" and .desk.result.status == "ready"' <<<"$body" >/dev/null || fail "desk: $body" +jq -e '.event.event == "pane.agent_status_changed" and (.event_id | startswith("p2:finished:"))' <<<"$body" >/dev/null || fail "raw event: $body" + +# key only in the 0600 header file, never argv or body +! grep -q "$SECRET" "$CURL_DIR/argv.log" || fail "key leaked into curl argv" +! grep -q "$SECRET" "$CURL_DIR/body.1" || fail "key leaked into body" +grep -q "Authorization: Bearer $SECRET" "$CURL_DIR/headers.1" || fail "bearer header" +grep -q "X-Automation-Key: $SECRET" "$CURL_DIR/headers.1" || fail "x-automation-key header" +leftover="$(find "${TMPDIR:-/tmp}" -maxdepth 1 -newer "$SINKS/work-shep.toml" -type f -name 'tmp.*' -exec grep -l "$SECRET" {} + 2>/dev/null || true)" +[[ -z "$leftover" ]] || fail "header temp file left behind" + +# duplicate / presentation-only / seen-flip events +fire p2 "done" +set_pane p2 idle claude agent +fire p2 idle +[[ "$(posts)" == 1 ]] || fail "dup done and done->idle must not post, got $(posts)" + +# stale event: herdr says working although the event says done +set_pane p2 working claude agent +fire p2 "done" +[[ "$(posts)" == 1 ]] || fail "stale done must be verified live, got $(posts)" + +# blocked once, then finished +set_pane p2 blocked claude agent +fire p2 blocked +fire p2 blocked +[[ "$(posts)" == 2 ]] || fail "blocked should post exactly once, got $(posts)" +jq -e '.kind == "blocked"' <<<"$(last_body)" >/dev/null || fail "blocked kind" +set_pane p2 idle claude agent +fire p2 idle +[[ "$(posts)" == 3 ]] || fail "blocked->idle should finish" + +# shell pane flaps never post (no agent / non-agent role) +set_pane p1 working "" center_shell +fire p1 working +set_pane p1 "done" "" center_shell +fire p1 "done" +set_pane p3 working claude review +fire p3 working +set_pane p3 "done" claude review +fire p3 "done" +[[ "$(posts)" == 3 ]] || fail "shell/review panes must not post, got $(posts)" + +# workspace filters route to the right sink +set_pane p7 working codex agent +fire p7 working "Personal-notes" "notes" +set_pane p7 "done" codex agent +fire p7 "done" "Personal-notes" "notes" +[[ "$(posts)" == 4 ]] || fail "perso sink should get it, got $(posts)" +grep -q 'hook/perso' "$CURL_DIR/url.4" || fail "routed to wrong sink" +grep -q "Authorization: Bearer $SECRET-perso" "$CURL_DIR/headers.4" || fail "key_file bearer" +! grep -q "X-Automation-Key" "$CURL_DIR/headers.4" || fail "auth=bearer must send one header" + +# pane gone: fall back to event status, unverified +set_pane p8 working claude agent +fire p8 working +rm -f "$PANES_DIR/p8.json" +fire p8 "done" +[[ "$(posts)" == 5 ]] || fail "unverified finish should still post" +jq -e '.pane.verified == false and .pane.status == "done"' <<<"$(last_body)" >/dev/null || fail "verified=false" + +# --- auth failure: stderr every time, one toast per episode, clears on 2xx -- +: >"$HERDR_CALL_LOG" +echo 401 >"$CURL_DIR/code" +for s in working "done" working "done"; do + set_pane p2 "$s" claude agent + fire p2 "$s" +done +[[ "$(grep -c 'notification show bercail notify: work-shep key rejected' "$HERDR_CALL_LOG")" == 1 ]] \ + || fail "one toast per auth episode: $(cat "$HERDR_CALL_LOG")" +[[ "$(grep -c "rejected its key (HTTP 401)" "$TMP_DIR/stderr.log")" == 2 ]] || fail "stderr each 401" +! grep -q "$SECRET\|example.invalid" "$TMP_DIR/stderr.log" || fail "secret or url in stderr" +jq -e '.auth_failed == true and .failures == 2' "$HERDR_PLUGIN_STATE_DIR/notify/sinks/work-shep.json" >/dev/null || fail "sink state" +echo 200 >"$CURL_DIR/code" +set_pane p2 working claude agent; fire p2 working +set_pane p2 "done" claude agent; fire p2 "done" +grep -q 'work-shep recovered' "$HERDR_CALL_LOG" || fail "recovery toast" +jq -e '.auth_failed == false and .failures == 0' "$HERDR_PLUGIN_STATE_DIR/notify/sinks/work-shep.json" >/dev/null || fail "recovered state" + +# --- 5xx retries once --------------------------------------------------------- +before="$(posts)" +echo 503 >"$CURL_DIR/code" +set_pane p2 working claude agent; fire p2 working +set_pane p2 "done" claude agent; fire p2 "done" +[[ "$(( $(posts) - before ))" == 2 ]] || fail "5xx should retry once" +echo 200 >"$CURL_DIR/code" + +# --- insecure sink files are refused ----------------------------------------- +chmod 644 "$SINKS/work-shep.toml" +before="$(posts)" +set_pane p2 working claude agent; fire p2 working +set_pane p2 "done" claude agent; fire p2 "done" +[[ "$(posts)" == "$before" ]] || fail "world-readable key sink must not post" +grep -q "work-shep' key file is readable by others" "$TMP_DIR/stderr.log" || fail "insecure warning" +chmod 600 "$SINKS/work-shep.toml" + +# --- a slow sink does not hold the lock for other panes ----------------------- +# Pane p2 (work sink) is mid-delivery for 3s; pane p7 (perso sink) finishes +# meanwhile. Its hook must classify and post without waiting for p2's POST. +set_pane p2 working claude agent; fire p2 working +set_pane p7 working codex agent; fire p7 working "Personal-notes" "notes" +before="$(posts)" +echo 3 >"$CURL_DIR/slow.work" +set_pane p2 "done" claude agent +fire p2 "done" & +slow_pid=$! +for _ in $(seq 50); do [[ -f "$CURL_DIR/count" && "$(posts)" -gt "$before" ]] && break; sleep 0.05; done +[[ "$(posts)" -gt "$before" ]] || fail "slow delivery never started" +set_pane p7 "done" codex agent +start=$SECONDS +fire p7 "done" "Personal-notes" "notes" +(( SECONDS - start < 2 )) || fail "hook waited on another pane's slow delivery" +grep -q 'hook/perso' "$CURL_DIR/url.$(posts)" || fail "perso finish not posted while work was slow" +wait "$slow_pid" +rm -f "$CURL_DIR/slow.work" +[[ "$(( $(posts) - before ))" == 2 ]] || fail "both finishes should post once, got $(( $(posts) - before ))" + +# --- pane.exited / workspace.closed forget saved statuses -------------------- +set_pane "w6N:p4" working claude agent; fire "w6N:p4" working +set_pane "w6N:p5" working claude agent; fire "w6N:p5" working +set_pane "w6Nx:p1" working claude agent; fire "w6Nx:p1" working +pane_state="$HERDR_PLUGIN_STATE_DIR/notify/panes" +[[ -f "$pane_state/w6N_p4.json" ]] || fail "pane state saved" +HERDR_PLUGIN_EVENT_JSON='{"event":"pane.exited","data":{"pane_id":"w6N:p4"}}' \ + bash "$PLUGIN_ROOT/notify.sh" pane-exited +[[ ! -f "$pane_state/w6N_p4.json" && -f "$pane_state/w6N_p5.json" ]] || fail "pane-exited forgets one pane" +HERDR_PLUGIN_EVENT_JSON='{"event":"workspace.closed","data":{"workspace_id":"w6N"}}' \ + bash "$PLUGIN_ROOT/notify.sh" workspace-closed +[[ ! -f "$pane_state/w6N_p5.json" ]] || fail "workspace-closed forgets its panes" +[[ -f "$pane_state/w6Nx_p1.json" ]] || fail "workspace-closed must not touch w6Nx" +# a reused id starting idle is a first sighting, not a finish +before="$(posts)" +set_pane "w6N:p4" idle claude agent; fire "w6N:p4" idle +[[ "$(posts)" == "$before" ]] || fail "reused pane id must not inherit a stale working" + +# --- a corrupt sink state file is replaced, not stuck ------------------------ +printf 'not json' >"$HERDR_PLUGIN_STATE_DIR/notify/sinks/work-shep.json" +set_pane p2 working claude agent; fire p2 working +set_pane p2 "done" claude agent; fire p2 "done" +jq -e '.last_code == "200" and .failures == 0' "$HERDR_PLUGIN_STATE_DIR/notify/sinks/work-shep.json" >/dev/null \ + || fail "corrupt sink state not rewritten" +! ls "$HERDR_PLUGIN_STATE_DIR/notify/sinks/"*.json.* >/dev/null 2>&1 || fail "sink state temp file left behind" + +# --- status / test never print secrets ---------------------------------------- +out="$(bash "$PLUGIN_ROOT/notify.sh" status)" +grep -q -- '- work-shep enabled=true url=set key=set' <<<"$out" || fail "status: $out" +grep -q -- '- off enabled=false' <<<"$out" || fail "status off: $out" +! grep -q "$SECRET\|example.invalid" <<<"$out" || fail "status leaked secrets" +out="$(bash "$PLUGIN_ROOT/notify.sh" test work-shep)" +[[ "$out" == "work-shep HTTP 200" ]] || fail "test: $out" +jq -e '.kind == "test"' <<<"$(last_body)" >/dev/null || fail "test payload" + +echo "notify_test: ok" diff --git a/skills/handoff/scripts/handoff-spawn b/skills/handoff/scripts/handoff-spawn index 9dee08a..ac46ce3 100755 --- a/skills/handoff/scripts/handoff-spawn +++ b/skills/handoff/scripts/handoff-spawn @@ -169,6 +169,8 @@ handoff_bool_json() { handoff_result_json() { local label="$1" sibling="$2" branch="$3" issue="$4" started="$5" copied="$6" graphite="$7" + local stage="${8:-}" harness="${9:-}" + # stage/harness let the layout plugin's notify.sh label desk events. jq -nc \ --arg label "$label" \ --arg path "$sibling" \ @@ -177,7 +179,11 @@ handoff_result_json() { --argjson agent_started "$(handoff_bool_json "$started")" \ --argjson dirty_copied "$(handoff_bool_json "$copied")" \ --argjson graphite "$(handoff_bool_json "$graphite")" \ - '{ok:true,label:$label,path:$path,branch:$branch,issue:$issue,agent_started:$agent_started,dirty_copied:$dirty_copied,graphite:$graphite}' + --arg stage "$stage" \ + --arg harness "$harness" \ + '{ok:true,label:$label,path:$path,branch:$branch,issue:$issue,agent_started:$agent_started,dirty_copied:$dirty_copied,graphite:$graphite} + + (if $stage == "" then {} else {stage:$stage} end) + + (if $harness == "" then {} else {harness:$harness} end)' } handoff_info_json() { @@ -414,7 +420,7 @@ handoff_spawn_main() { echo "handoff-spawn: handoff-agent did not confirm a live agent; worktree is still $sibling ($label)" >&2 fi - json="$(handoff_result_json "$label" "$sibling" "$branch" "$issue" "$started" "$copied" "$graphite")" + json="$(handoff_result_json "$label" "$sibling" "$branch" "$issue" "$started" "$copied" "$graphite" "$stage" "$binary")" handoff_remember "$json" printf '%s\n' "$json" } diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index 9ef4ce6..8922b61 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -164,6 +164,9 @@ test_result_json_records_unconfirmed_agent() { printf '%s' "$got" | jq -e '.agent_started == false' >/dev/null || fail "started: $got" printf '%s' "$got" | jq -e '.graphite == true' >/dev/null || fail "graphite: $got" printf '%s' "$got" | jq -e '.path == "/tmp/wt"' >/dev/null || fail "path: $got" + printf '%s' "$got" | jq -e '(has("stage")|not) and (has("harness")|not)' >/dev/null || fail "no stage: $got" + got="$(handoff_result_json "Lbl" "/tmp/wt" "br" "bercail-0s1" 1 0 0 start claude)" + printf '%s' "$got" | jq -e '.stage == "start" and .harness == "claude"' >/dev/null || fail "stage/harness: $got" printf 'PASS: result JSON is emitted when agent start is unconfirmed\n' }