Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# bercail

An agentic development environment on the Herdr app: one worktree per space, one
desk agent per space, review in tuicr. Bash CLI plus Herdr mods, skills, and docs.

## Layout

- `bin/bercail`: the CLI entry point. It sources `lib/*.sh`, one file per area
(`notify.sh`, `doctor.sh`, `desk.sh`, `bstack.sh`, …).
- `install.sh`: installer and deploy; `manifest.json` holds the version.
- `config/`: files deployed under `~/.config` (shell, worktrunk, pi, opencode, systemd, …).
- `bercail/`: the Herdr layout and mods (`bercail/mods/*`).
- `skills/`: skills deployed to `~/.agents/skills`. `bstack/` is the bstack Pi/opencode setup.
- `tests/*_test.sh`: one bash script per area, each in its own temp HOME with stand-ins
(`hc`, `curl`, `openclaw`, …). `.agents/skills/verify-bercail/` drives the CLI end to end.
- `docs/`: the reference (`docs/README.md` is the index). `site/` and `workers/` are
the bercail.dev site and its Worker.

## Commands

```bash
bin/bercail <cmd> # this checkout's CLI; a bare `bercail` runs the installed copy
bash tests/notify_test.sh # one area
for t in tests/*_test.sh; do bash "$t" || break; done # everything (CI runs tests/*.sh)
shellcheck -S warning lib/notify.sh # lint the files you touch; the tree has older warnings
herdr-app check bercail/mods/bercail-desk # each mod you touch
.agents/skills/verify-bercail/run.sh # throwaway-HOME drive; evidence in .bercail/verify/
```

There is no build step for the CLI. The site build needs `npm ci` first (see
`docs/development.md`).

Known noise (2026-11, v0.12.0): `verify-bercail` reports four FAIL checks (doctor's bstack
section, ready.md, council command and seat) on a clean HEAD too. Under `~/Work`,
mise in its temp HOME tries to install tools, so its `reconfigure` steps fail as well.
Run it from a copy under `/tmp` to compare. `tests/handoff_spawn_test.sh` can fail its
Ctrl-C check when the machine is busy; rerun it on its own.

## Conventions

- Match the code beside you: small bash functions with a one-line comment on why,
`local` everything, `jq` for JSON, errors on stderr with a fix in the message.
- Secrets never go in argv or output. Pass them on stdin, in a 0600 file, or through
a header file (see `notify_post`). Tests grep argv logs and output for the secret.
- A behavior change comes with a test in `tests/` that fails without it. Use
stand-ins in `$TMP_DIR/bin`, never the real HOME, Herdr core, or network.
- Docs speak plainly and match the code; update `docs/` and the help text in
`bin/bercail` and `lib/help.sh` together.
- Leave changes staged and uncommitted for review in tuicr. Use `gh` and `gh stack`
for git hosting; never `gt`.
2 changes: 1 addition & 1 deletion bin/bercail
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Commands:
harness JSON: which of claude, codex, cursor-agent, opencode, pi are on PATH
bstack Pi (opencode only with harness = "opencode"): status | setup | on | off
desk Bring back the desk agents a reboot took: desk restore [--dry-run]
notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | run
notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | openclaw | run
review-pr Review someone else's PR by intent and decisions: review-pr <PR url> | run | card | ui | open
update Re-sync configs, Herdr layout and mod, helper, and skills
reconfigure Refresh skills/integrations from config.toml (not a full redeploy)
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The `bercail` CLI, the shell helpers, and the skills your agents call by name. `
| `bercail reconfigure` | Re-read `config.toml`, refresh skills |
| `bercail dry-run` | Show what install would do, without doing it |
| `bercail uninstall` | Remove managed files and the shell marker |
| `bercail notify status` / `add NAME` / `repo add NAME [REPO]` / `repo remove NAME REPO` / `check [PATH]` / `test NAME` / `run` | [Agent status webhooks](shep.md#agent-status-webhooks). No subcommand = `status`; `run` is the follower the desk mod keeps alive |
| `bercail notify status` / `add NAME` / `repo add NAME [REPO]` / `repo remove NAME REPO` / `check [PATH]` / `test NAME` / `openclaw [NAME]` / `run` | [Agent status webhooks](shep.md#agent-status-webhooks). No subcommand = `status`; `openclaw` points a sink at [a local OpenClaw Gateway](shep.md#openclaw); `run` is the follower the desk mod keeps alive |

## shell

Expand Down
34 changes: 34 additions & 0 deletions docs/shep.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,37 @@ Each POST body is `bercail.agent_status/v2`:
```

`agent.address` is the hc terminal (`hc get`/`hc read` it). `desk.issue`, `desk.issue_title`, `stage`, and `harness` come from the matching `handoffs.jsonl` record (`harness` falls back to `agent.app`; `issue_title` is null when that record has none). `desk.result` is `status`, `summary` (its first 500 characters), and `session_id` from the checkout's `.bercail/handoff-result.json` when one exists (usually `null`: headless runs have no desk terminal, so they send nothing). `text` is one line for a person: `<id>: <title>. Desk <address> (<space name>) finished.` or `is blocked.`; the bare id when there is no title, no parentheses when the space has no name, and `Test from bercail (sink <name>).` for a test post.

### OpenClaw

`bercail notify openclaw` points a sink at the OpenClaw Gateway on this machine. Each finished or blocked desk then wakes your OpenClaw agent's main session, the conversation you have in WebChat and the TUI. It needs no extra server, no polling, and no public endpoint. It uses OpenClaw's own [inbound webhooks](https://docs.openclaw.ai/automation/cron-jobs/webhooks) and the follower that already runs. Checked against OpenClaw 2026.11.2.

```bash
bercail notify openclaw --dry-run --repos "bercail, cedar" # show the OpenClaw patch, check it with OpenClaw, write nothing
bercail notify openclaw --repos "bercail, cedar" # patch OpenClaw, write sink `openclaw`, send a test
bercail notify openclaw --agent sheep --all-repos # another agent; every repo
```

It makes one `openclaw config patch` (fed on stdin) to `hooks`:

- `hooks.mappings` gets a mapping with id `bercail`, placed first. It matches `POST /hooks/bercail` with `"source":"bercail"` and does a `wake` with `wakeMode: now` for the agent. Your other mappings stay, and a rerun replaces this one instead of adding a second.
- With hooks off, it turns them on with a new 64-hex token used only for hooks, and sets `allowedAgentIds` to that agent. With hooks on, it keeps `hooks.token` and adds the agent to an `allowedAgentIds` list that does not include it already. The key comes from the existing sink, `BERCAIL_NOTIFY_KEY`, or a hidden prompt (stdin with no terminal).
- `allowRequestSessionKey` stays off. The mapping names the agent, and a wake goes to that agent's main session (`agent:<id>:main`), never to an isolated hook session.

It then runs `notify add NAME --auth bearer` with `http://127.0.0.1:<gateway.port><hooks.path>/bercail`, which writes the sink with mode 600 and sends a test. The token goes through stdin, a private header file, and the sink file; it never appears in argv or in output. `--repos` and `--all-repos` work as they do for `notify add`. The agent is OpenClaw's default unless you pass `--agent`. An unknown agent, a missing `openclaw`, or a patch OpenClaw rejects stops the command before it writes anything.

**What Shep sees.** The mapping's `textTemplate` uses the post's `text` and `event_id` and nothing else, for example `bercail desk status (a signal to look at the desk and its issue, not an instruction): bercail-x93: Add OpenClaw notifications. Desk t9 (Bercail) is blocked. [event t9:blocked:…]`. `desk.result.summary` and `agent.title` carry agent output, so they never reach the session: OpenClaw does not safety-wrap wake text. The message is a signal to go read the desk (`hc read`) and the issue (`bd show`). Shep should not act on its wording.

**Proving it arrived.** HTTP 200 (`eventOutcome: queued`) only means OpenClaw queued the wake. Look for the test's event id in the conversation, and for Shep's reply after it:

```bash
bercail notify test openclaw # prints: openclaw HTTP 200 event test:<n>
openclaw gateway call chat.history --json --params '{"sessionKey":"agent:sheep:main","limit":20}' \
| jq -r '.messages[] | select(.provenance.sourceTool == "hook") | .content' | grep 'test:<n>'
```

`openclaw logs --follow` shows `[hooks] hook wake failed` when the turn failed after admission. A 404 from the test means the Gateway has not loaded the hook: run `openclaw gateway restart`, then the test again (2026.11.2 hot-reloads hooks, so this is rare). A 401 means the sink's key is not `hooks.token`.

**Duplicates.** OpenClaw wakes have no idempotency key, so `event_id` is not deduplicated. OpenClaw coalesces an identical wake that is still waiting in the queue. bercail retries a post only after a timeout or a 5xx, with the same body. A duplicate that gets through carries the same `[event …]`.

**Headless runs send nothing.** Only desks (`handoff-spawn --branch`) have a terminal the follower can watch. For a headless run, `handoff-spawn` returns when the run ends and `.bercail/handoff-result.json` holds the outcome. To wait on a desk without the webhook, use `hc wait <desk>`.
2 changes: 1 addition & 1 deletion lib/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Post-install CLI (bercail):
reconfigure Refresh skills from config.toml (not a full redeploy)
dry-run Show planned actions without changes
uninstall Remove marker block and managed files
notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | run
notify Agent status webhooks: status | add NAME | repo add|remove NAME | test NAME | openclaw | run
review-pr Intent and decisions for a whole PR stack: review-pr <PR url-or-branch>
gathers every PR, with one detached sibling worktree and a Stack tab.
Cards run in parallel; thin PRs wait. enter: select PR, d: parent diff,
Expand Down
148 changes: 144 additions & 4 deletions lib/notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# bercail notify add NAME write sink NAME from a pasted url and key, then test it
# bercail notify repo add|remove NAME [REPO] add a repo to sink NAME, or take it out
# bercail notify check [PATH] warn if no enabled sink covers PATH's repo
# bercail notify openclaw [NAME] point a sink at this machine's OpenClaw Gateway hook
#
# The desk mod keeps one `run` alive as the hidden hc terminal bercail-notify.

Expand Down Expand Up @@ -536,19 +537,20 @@ notify_status() {
}

notify_test() {
local name="${1:-}" f code agent space desk
local name="${1:-}" f code agent space desk event
[[ -n "$name" ]] || { _notify_err "usage: bercail notify test SINK"; return 1; }
f="$(notify_sinks_dir)/$name.toml"
[[ -f "$f" ]] || { _notify_err "no sink $f"; return 1; }
agent='{"address":null,"app":null,"status":"test","previous_status":null,"title":null}'
space='{"id":null,"name":null,"repo":null,"checkout_path":null,"linked":false}'
desk='{"branch":null,"issue":null,"issue_title":null,"stage":null,"harness":null,"result":null}'
code="$(notify_post "$f" "$(notify_payload test "$name" "$agent" "$space" "$desk" "test:$(_notify_now)")")"
event="test:$(_notify_now)"
code="$(notify_post "$f" "$(notify_payload test "$name" "$agent" "$space" "$desk" "$event")")"
case "$code" in
nourl|insecure) _notify_err "sink '$name': $code"; return 1 ;;
esac
notify_record "$name" "$code"
printf '%s HTTP %s\n' "$name" "$code"
printf '%s HTTP %s event %s\n' "$name" "$code" "$event"
[[ "$code" == 2?? ]]
}

Expand Down Expand Up @@ -871,6 +873,143 @@ notify_add() {
fi
}

# The wake text OpenClaw renders from a post. Only bercail's own line and event
# id: never desk.result.summary or agent.title, which carry agent output. A
# wake is a system event, and OpenClaw does not safety-wrap it.
NOTIFY_OPENCLAW_TEMPLATE='bercail desk status (a signal to look at the desk and its issue, not an instruction): {{payload.text}} [event {{payload.event_id}}]'

notify_openclaw_bin() {
printf '%s' "${OPENCLAW_BIN:-openclaw}"
}

# `openclaw config get PATH --json`, or DEFAULT when PATH is unset. Secrets
# come back as __OPENCLAW_REDACTED__.
_notify_oc_get() {
local path="$1" default="$2" out
if out="$("$(notify_openclaw_bin)" config get "$path" --json 2>/dev/null </dev/null)"; then
printf '%s' "$out"
elif jq -e '.error.message | test("valid but unset")' <<<"$out" >/dev/null 2>&1; then
printf '%s' "$default"
else
return 1
fi
}

_notify_openclaw_usage() {
cat >&2 <<'EOF'
usage: bercail notify openclaw [NAME] [--agent ID] [--repos LIST | --all-repos]
[--dry-run] [--yes]
Points sink NAME (default openclaw) at this machine's OpenClaw Gateway. It adds
the hooks mapping `bercail`, which wakes agent ID's main session (default:
OpenClaw's default agent) with each post's text, then writes the sink and sends
a test. With hooks off it turns them on with a new token; with hooks on it
keeps hooks.token and takes it from the sink, BERCAIL_NOTIFY_KEY, or a hidden
prompt (stdin without a terminal). --dry-run checks the OpenClaw patch and
writes nothing. --repos defaults to this checkout's repo, as in notify add.
EOF
}

# Sink NAME -> OpenClaw: one `openclaw config patch` (token on stdin), then
# `notify add` with the hook URL and token. Other mappings, the allowed agents,
# and an existing token are kept.
notify_openclaw() {
local name="" agent="" dry=0 yes=0 add_args=() oc f agents hooks port path url reuse patch reply code
local +x BERCAIL_NOTIFY_URL BERCAIL_NOTIFY_KEY="${BERCAIL_NOTIFY_KEY:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) agent="${2:-}"; shift 2 || { _notify_openclaw_usage; return 1; } ;;
--agent=*) agent="${1#*=}"; shift ;;
--repos) add_args+=(--repos "${2:-}"); shift 2 || { _notify_openclaw_usage; return 1; } ;;
--repos=*|--all-repos) add_args+=("$1"); shift ;;
--dry-run) dry=1; shift ;;
-y|--yes) yes=1; shift ;;
-h|--help) _notify_openclaw_usage; return 0 ;;
-*) _notify_err "unknown option '$1'"; _notify_openclaw_usage; return 1 ;;
*)
[[ -z "$name" ]] || { _notify_err "one sink name only"; _notify_openclaw_usage; return 1; }
name="$1"; shift ;;
esac
done
name="${name:-openclaw}"
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|| { _notify_err "sink name '$name': use letters, digits, dot, dash, underscore"; return 1; }
f="$(notify_sinks_dir)/$name.toml"
oc="$(notify_openclaw_bin)"
command -v "$oc" >/dev/null 2>&1 || { _notify_err "openclaw is not on PATH"; return 1; }

agents="$("$oc" agents list --json 2>/dev/null </dev/null)" \
&& jq -e 'type == "array"' <<<"$agents" >/dev/null 2>&1 \
|| { _notify_err "could not list OpenClaw agents (openclaw agents list --json)"; return 1; }
[[ -n "$agent" ]] || agent="$(jq -r 'map(select(.isDefault))[0].id // empty' <<<"$agents")"
[[ -n "$agent" ]] || { _notify_err "OpenClaw has no default agent; pass --agent ID"; return 1; }
jq -e --arg a "$agent" 'any(.[]; .id == $a)' <<<"$agents" >/dev/null \
|| { _notify_err "no OpenClaw agent '$agent' (agents: $(jq -r 'map(.id) | join(", ")' <<<"$agents"))"; return 1; }
hooks="$(_notify_oc_get hooks '{}')" && port="$(_notify_oc_get gateway.port 18789)" \
|| { _notify_err "could not read the OpenClaw config (openclaw config get hooks --json)"; return 1; }
path="$(jq -r '.path // "/hooks" | sub("/+$"; "") | if startswith("/") then . else "/" + . end' <<<"$hooks")"
url="http://127.0.0.1:$port$path/bercail"
reuse="$(jq -r '.enabled == true and (.token // "") != ""' <<<"$hooks")"

# Ours goes first: OpenClaw runs the first mapping that matches.
patch="$(jq -c --arg agent "$agent" --arg tpl "$NOTIFY_OPENCLAW_TEMPLATE" --argjson reuse "$reuse" '
. as $h
| {id:"bercail", match:{path:"bercail", source:"bercail"}, action:"wake", wakeMode:"now",
agentId:$agent, textTemplate:$tpl} as $m
| {enabled:true, mappings:([$m] + [($h.mappings // [])[] | select(.id != "bercail")])}
+ if ($h.allowedAgentIds | type) == "array" then
(if any($h.allowedAgentIds[]; . == $agent or . == "*") then {}
else {allowedAgentIds:($h.allowedAgentIds + [$agent])} end)
elif $reuse then {}
else {allowedAgentIds:[$agent]} end
| {hooks:.}' <<<"$hooks")"

printf 'OpenClaw: agent %s, hook %s, token %s\n' "$agent" "$url" \
"$([[ "$reuse" == true ]] && printf 'kept (hooks are on)' || printf 'new')"
jq . <<<"$patch"
if [[ -e "$f" && "$yes" -ne 1 && "$dry" -ne 1 ]]; then
[[ -t 0 ]] || { _notify_err "sink '$name' exists; pass --yes to replace it"; return 1; }
read -r -p "Sink $name exists. Replace it? [y/N] " reply || reply=""
[[ "$reply" == [yY]* ]] || { printf 'kept %s\n' "$f" >&2; return 1; }
fi

if [[ "$dry" -eq 1 ]]; then
[[ "$reuse" == true ]] || patch="$(jq -c '.hooks.token = "dry-run-placeholder"' <<<"$patch")"
printf '%s' "$patch" | "$oc" config patch --stdin --dry-run >&2 \
|| { _notify_err "OpenClaw rejected the patch"; return 1; }
printf 'dry run: would patch OpenClaw and %s sink %s (%s, auth bearer); wrote nothing\n' \
"$([[ -e "$f" ]] && printf replace || printf write)" "$name" "$f"
return 0
fi

if [[ "$reuse" == true ]]; then
[[ -n "$BERCAIL_NOTIFY_KEY" || ! -f "$f" ]] || BERCAIL_NOTIFY_KEY="$(notify_sink_key "$f" 2>/dev/null || true)"
[[ -n "$BERCAIL_NOTIFY_KEY" ]] || BERCAIL_NOTIFY_KEY="$(_notify_read_value "OpenClaw hooks.token (hidden): " 1)"
[[ -n "$BERCAIL_NOTIFY_KEY" ]] || { _notify_err "hooks are on: give their token (hooks.token) on stdin or in BERCAIL_NOTIFY_KEY"; return 1; }
else
BERCAIL_NOTIFY_KEY="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
patch="$(printf '%s' "$BERCAIL_NOTIFY_KEY" | jq -Rc --argjson p "$patch" '. as $t | $p | .hooks.token = $t')"
fi
printf '%s' "$patch" | "$oc" config patch --stdin >&2 \
|| { _notify_err "OpenClaw rejected the patch; sink $name not written"; return 1; }
patch=""

BERCAIL_NOTIFY_URL="$url"
code=0
notify_add "$name" --auth bearer --yes "${add_args[@]}" || code=$?
BERCAIL_NOTIFY_KEY=""
if [[ "$code" -ne 0 ]]; then
case "$(jq -r '.last_code // empty' "$(_notify_sink_state_file "$name")" 2>/dev/null)" in
404) _notify_err "the Gateway has not loaded the hook yet: openclaw gateway restart, then bercail notify test $name" ;;
401) _notify_err "the key is not OpenClaw's hooks.token; rerun with the right one: bercail notify openclaw $name --yes" ;;
000) _notify_err "is the Gateway running on port $port? openclaw gateway status, then bercail notify test $name" ;;
esac
fi
printf '%s\n' "HTTP 200 only means OpenClaw queued the wake. To see it in agent $agent's conversation:" \
" openclaw gateway call chat.history --json --params '{\"sessionKey\":\"agent:$agent:main\",\"limit\":20}' \\" \
" | jq -r '.messages[] | select(.provenance.sourceTool == \"hook\") | .content'"
return "$code"
}

cmd_notify() {
case "${1:-status}" in
run) notify_run ;;
Expand All @@ -879,6 +1018,7 @@ cmd_notify() {
add) shift; notify_add "$@" ;;
repo) shift; notify_repo "$@" ;;
check) notify_check "${2:-}" ;;
*) _notify_err "unknown command '${1:-}' (run|status|test NAME|add NAME|repo add|remove NAME [REPO]|check [PATH])"; return 1 ;;
openclaw) shift; notify_openclaw "$@" ;;
*) _notify_err "unknown command '${1:-}' (run|status|test NAME|add NAME|repo add|remove NAME [REPO]|check [PATH]|openclaw [NAME])"; return 1 ;;
esac
}
Loading
Loading