From 468540785b638e548c8704e8c24eb2dd7e171401 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Sun, 8 Nov 2026 17:25:43 -0500 Subject: [PATCH] Show the issue title next to every bd and desk id Handoff now carries the issue's short title alongside the bd id and desk id: in Herdr desk labels, the first prompt sent to the desk, the desk record, notify messages and webhook payloads, and the handoff docs, so every mention is human readable. --- bin/bercail-desk-agent | 16 +- bstack/pi/instructions.md | 3 + config/worktrunk/herdr-layout.sh | 13 +- docs/config.md | 2 +- docs/handoff.md | 6 +- docs/shep.md | 7 +- lib/desk.sh | 16 +- lib/doctor.sh | 16 +- lib/notify.sh | 20 ++- skills/handoff/SKILL.md | 10 +- skills/handoff/resources/handoff.md | 12 +- skills/handoff/scripts/handoff-spawn | 22 ++- skills/handoff/scripts/handoff-stages.sh | 52 +++++- tests/desk_agent_test.sh | 14 +- tests/desk_restore_test.sh | 14 ++ tests/handoff_spawn_test.sh | 202 ++++++++++++++++++++++- tests/herdr_layout_worktree_test.sh | 17 +- tests/notify_test.sh | 57 ++++++- 18 files changed, 440 insertions(+), 59 deletions(-) diff --git a/bin/bercail-desk-agent b/bin/bercail-desk-agent index f951062..30e31b4 100755 --- a/bin/bercail-desk-agent +++ b/bin/bercail-desk-agent @@ -120,7 +120,7 @@ flag_has_value() { # Recorded args are what a resume can replay beside an explicit session: not a # prompt, not "continue the latest", and not the session flag restore adds. remember_desk() { - local harness session="" issue="" space record tmp flags="[]" arg name + local harness session="" issue="" issue_title="" space record tmp flags="[]" arg name hcjson="" local dir="${XDG_STATE_HOME:-$HOME/.local/state}/bercail/desk-sessions" local -a rest=() local saw_pos=0 @@ -181,8 +181,8 @@ remember_desk() { fi rest+=("$arg") done - space="$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null \ - | jq -r 'select(.labels["bercail.role"] == "agent") | .labels["bercail.space"] // empty')" || return 0 + hcjson="$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null)" || return 0 + space="$(jq -r 'select(.labels["bercail.role"] == "agent") | .labels["bercail.space"] // empty' <<<"$hcjson")" || return 0 [[ -n "$space" ]] || return 0 [[ "$prompt" =~ beads\ issue\ ([A-Za-z0-9_-]+) ]] && issue="${BASH_REMATCH[1]}" # Private only when we create it. An existing dir keeps its mode, and this @@ -195,14 +195,20 @@ remember_desk() { record="$dir/$HC_TERMINAL_ID.json" # A resume has no prompt: the issue stays what it was in this terminal. [[ -n "$issue" ]] || issue="$(jq -r '.issue // empty' "$record" 2>/dev/null)" || issue="" + # Label title when it names this issue; else the record's, on resume. + if [[ -n "$issue" ]] && jq -e --arg issue "$issue" '.labels["bercail.issue"] == $issue' <<<"$hcjson" >/dev/null 2>&1; then + issue_title="$(jq -r '.labels["bercail.issue_title"] // ""' <<<"$hcjson" 2>/dev/null)" || issue_title="" + elif [[ -n "$issue" && -r "$record" ]]; then + issue_title="$(jq -r --arg issue "$issue" 'if (.issue // "") == $issue then .issue_title // "" else "" end' "$record" 2>/dev/null)" || issue_title="" + fi [[ ${#rest[@]} -eq 0 ]] || flags="$(jq -cn --args '$ARGS.positional' -- "${rest[@]}")" # 0600 in a subshell, so the harness keeps the umask it was started with. tmp="$(umask 077 && mktemp "$dir/.${HC_TERMINAL_ID}.json.XXXXXX")" || return 0 if ! jq -n --arg address "$HC_TERMINAL_ID" --arg session "$session" --arg cwd "$PWD" \ - --arg harness "$harness" --arg issue "$issue" --arg space "$space" \ + --arg harness "$harness" --arg issue "$issue" --arg issue_title "$issue_title" --arg space "$space" \ --argjson pid "$$" --argjson args "$flags" \ '{address: $address, session: $session, cwd: $cwd, harness: $harness, - issue: $issue, space: $space, args: $args, pid: $pid}' >"$tmp" || ! mv "$tmp" "$record"; then + issue: $issue, issue_title: $issue_title, space: $space, args: $args, pid: $pid}' >"$tmp" || ! mv "$tmp" "$record"; then rm -f "$tmp" return 0 fi diff --git a/bstack/pi/instructions.md b/bstack/pi/instructions.md index 1ecc7ca..879af0b 100644 --- a/bstack/pi/instructions.md +++ b/bstack/pi/instructions.md @@ -8,6 +8,8 @@ at it. These rules apply to every bstack agent. - The job is one thing, written down. When a beads issue names it (`bd show `), that issue and its comments are the spec. Re-read them before you stop. +- When you name a beads issue or a desk to the person or a sibling (reports, + `hc send`, `bd comment`), write it as `: `, not the bare id. - Restate the goal in one sentence before non-trivial work. If the ask is too thin to act on, say what is missing in one message, then do the part you can. - Load the `bstack` skill for anything beyond a one-line change. It holds the @@ -52,6 +54,7 @@ Start one from your own terminal: space="$(hc get "$HC_TERMINAL_ID" --json | jq -r '.labels["bercail.space"] // empty')" hc run --bg --name <issue>-build --cwd <worktree> \ --label bercail.role=bstack-builder --label "bercail.space=$space" --label bercail.issue=<id> \ + --label "bercail.issue_title=<title>" \ bstack-builder "<brief>. Write long output to a file. Report with hc send $HC_TERMINAL_ID when done or stuck." ``` diff --git a/config/worktrunk/herdr-layout.sh b/config/worktrunk/herdr-layout.sh index 48f8aa2..e775e4b 100755 --- a/config/worktrunk/herdr-layout.sh +++ b/config/worktrunk/herdr-layout.sh @@ -137,11 +137,20 @@ _wt_herdr_start_desk() { read -r -a cmd <<<"$WT_HERDR_AGENT_CMD" argv+=("${cmd[@]}") fi + # Handoff names the issue on the desk. A default start clears those vars. + local -a issue_labels=() + if [[ -n "${WT_HERDR_ISSUE:-}" ]]; then + issue_labels+=(--label "bercail.issue=${WT_HERDR_ISSUE}") + if [[ -n "${WT_HERDR_ISSUE_TITLE:-}" ]]; then + issue_labels+=(--label "bercail.issue_title=${WT_HERDR_ISSUE_TITLE}") + fi + fi # hc passes on the caller's environment; a headless caller (Shep) has # TERM=dumb, which leaves the desk without colors or key handling. TERM=xterm-256color COLORTERM=truecolor "$HC" run --bg --keep --outlive-parent --no-notify \ --parent "$shell" --cwd "$(_wt_realpath "$workdir")" \ --label bercail.role=agent --label "bercail.space=$space" \ + "${issue_labels[@]}" \ "${argv[@]}" >/dev/null } @@ -159,12 +168,12 @@ wt_herdr_layout_create() { } # Clear session of the configured agent (d / dev / apply). Ignores any -# inherited handoff prompt. +# inherited handoff prompt or issue labels. wt_herdr_start_default_agent() { local label="$1" workdir="$2" space _wt_herdr_app_running || { echo "Herdr app is not running" >&2; return 1; } space="$(_wt_herdr_resolve_space "$label" "$workdir")" || return 1 - WT_HERDR_AGENT_CMD= _wt_herdr_start_desk "$space" "$workdir" + WT_HERDR_AGENT_CMD= WT_HERDR_ISSUE= WT_HERDR_ISSUE_TITLE= _wt_herdr_start_desk "$space" "$workdir" } # Orchestrator / handoff-spawn: start or replace the desk agent with diff --git a/docs/config.md b/docs/config.md index 4d1e241..f386bac 100644 --- a/docs/config.md +++ b/docs/config.md @@ -45,7 +45,7 @@ After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and b Herdr brings back spaces, tabs, and each desk's terminal, but as a bare shell: it never reruns a program. So `bercail-desk-agent` writes down what ran there, and the desk comes back on the same session in the same space, with no prompt sent again. -- **The record.** `~/.local/state/bercail/desk-sessions/<address>.json` holds `session`, `cwd`, `harness`, `issue`, `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off; on a Pi desk the `bercail-desk` extension records Pi's session the same way. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record. +- **The record.** `~/.local/state/bercail/desk-sessions/<address>.json` holds `session`, `cwd`, `harness`, `issue`, `issue_title` (empty when unknown), `space`, `args` (resume-safe flags) and `pid`, keyed by the terminal's address. A local opencode TUI plugin records the selected session, even while idle and with bstack off; on a Pi desk the `bercail-desk` extension records Pi's session the same way. A watcher touches the file every minute while the desk lives. Claude's session comes from a `SessionStart` hook. Headless runs get no record. - **What comes back.** A record fresh within ten minutes of shutdown (the last journal line of the previous boot), on a desk at a bare shell, in a folder that still exists, whose session is not already open. Records written since shutdown also qualify for a manual restore after a core stop/start. A desk you quit has finished, so it stays quit. Older records stay shells. - **Duplicate checks.** Restore checks process arguments, all live Herdr terminals' status records, and the opencode plugin's and Pi extension's runtime claims in `$XDG_RUNTIME_DIR/bercail/open-sessions/`. Claims include idle sessions and tabs, and count only while their original process is alive. TUIs started before the plugin was installed may have no claim: check those manually before restoring. - **When.** Once per boot, through Omarchy's post-boot hook or else a systemd user unit: it waits until you open Herdr, then a notification says how many came back. Omarchy logs to `~/.local/state/bercail/desk-restore.log`; systemd logs to `journalctl --user -u bercail-desk-restore`. After a core restart, or to run it yourself, use `bercail desk restore`; `--dry-run` lists each desk and why. diff --git a/docs/handoff.md b/docs/handoff.md index 1c11a5e..4d8dac5 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -26,11 +26,11 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) `--base BRANCH` cuts the desk branch from BRANCH instead of the default branch, so a dependent issue's desk stacks on its parent's branch. The [git backend](#git-backend) registers that parent with `gh stack`, `gt track`, or `origin stack track`. It works only with `--branch`, and stage `autopilot` can't take `--branch`: give it `--worktree` on the PR's checkout. -**Prompt.** One line pointing at the issue, the same for every harness: ``Your job is beads issue <id>. Run `bd show <id>` (read its comments too) and do the work it describes.`` +**Prompt.** One line pointing at the issue, the same for every harness: ``Your job is beads issue <id>: <title>. Run `bd show <id>` (read its comments too) and do the work it describes.`` The title is the issue's, truncated to 60 characters. When bd has none, the sentence keeps the id only: ``Your job is beads issue <id>. Run `bd show <id>` (read its comments too) and do the work it describes.`` **Context.** Before it starts the agent, bercail adds a `bd comment` to the issue with the worktree and, headless, the result file and session id. A worktree's `.beads/redirect` points `bd` at the main checkout's database, so every comment lands in one store. -**Result.** A headless run leaves its CLI log in `<worktree>/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`, plus an optional `verification` array of `{kind: test|verify-skill|statechart, command, exit_code, evidence}` from `/ready` (a malformed array is dropped; the rest of the result stands). A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`). +**Result.** A headless run leaves its CLI log in `<worktree>/.bercail/start.log` and `{status: ready|blocked|failed, summary, session_id}`, plus `issue` and, when known, `issue_title`, and an optional `verification` array of `{kind: test|verify-skill|statechart, command, exit_code, evidence}` from `/ready` (a malformed array is dropped; the rest of the result stands). A plan usage limit also sets `reason` to `usage-limit`, so Shep can propose another plan. Bercail does not switch or rerun. Read that file, not terminal output. `--resume SESSION` continues the same session (`claude --resume`, `codex exec resume`, `cursor-agent --resume`, `opencode run --session`). **Blocked, not switched.** Headless, if the stage's binary, `bd`, or the issue is missing, the result is `blocked` and nothing starts. With `--branch`, `handoff-spawn` exits 1 before it makes a worktree. Bercail never falls through to another harness. `--binary claude|codex|cursor-agent|opencode` runs a headless stage on another CLI only when you ask (with `opencode`, `start` stays on Claude and `codex` on GPT, through your opencode plans; stage `autopilot` takes only `opencode`). @@ -38,7 +38,7 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) **Which harnesses?** `bercail harness` reports which of claude, codex, cursor-agent, and opencode are on PATH, with a local model hint. It does not choose. -Each `--branch` spawn is logged in `${XDG_STATE_HOME:-~/.local/state}/agentic-dev/handoffs.jsonl`; headless runs are not logged there. Follow a `--branch` desk through [agent status webhooks](shep.md#agent-status-webhooks) or with `hc wait <desk>`; `desk` and `space` (and `base`, when set) are in the spawn JSON. +Each `--branch` spawn is logged in `${XDG_STATE_HOME:-~/.local/state}/agentic-dev/handoffs.jsonl`; headless runs are not logged there. Follow a `--branch` desk through [agent status webhooks](shep.md#agent-status-webhooks) or with `hc wait <desk>`; `desk`, `space`, and, when known, `issue_title` (and `base`, when set) are in the spawn JSON. ## Git backend diff --git a/docs/shep.md b/docs/shep.md index e6c5b9c..31595c2 100644 --- a/docs/shep.md +++ b/docs/shep.md @@ -61,8 +61,9 @@ Each POST body is `bercail.agent_status/v2`: "at":"2026-10-29T22:42:00Z","event_id":"t9:finished:1793312520:4242", "agent":{"address":"t9","app":"claude-code","status":"done","previous_status":"working","title":"..."}, "space":{"id":"sp98de","name":"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":"..."}}} + "desk":{"branch":"feat/render","issue":"lef2745-2vs","issue_title":"Fix the login timeout","stage":"start","harness":"claude", + "result":{"status":"ready","summary":"...","session_id":"..."}}, + "text":"lef2745-2vs: Fix the login timeout. Desk t9 (Render-preview_Leftlane_app) finished."} ``` -`agent.address` is the hc terminal (`hc get`/`hc read` it). `desk.issue`, `stage`, and `harness` come from the matching `handoffs.jsonl` record (`harness` falls back to `agent.app`). `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). +`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. diff --git a/lib/desk.sh b/lib/desk.sh index 7b3b494..8cbee4a 100644 --- a/lib/desk.sh +++ b/lib/desk.sh @@ -208,7 +208,7 @@ _desk_confirm_resume() { # on the next pass, and a second line would land in the agent's input. declare -gA DESK_TYPED=() _desk_restore_pass() { - local dry="$1" facts plan shutdown row address action why record what confirm + local dry="$1" facts plan shutdown row address action why record what confirm named tag DESK_RESUMED=0 DESK_WAITING=0 facts="$(_desk_gather)" || { warn "hc did not answer: desks left alone"; return 1; } shutdown="$(_desk_shutdown_time)" @@ -219,9 +219,13 @@ _desk_restore_pass() { action="$(jq -r .action <<<"$row")" why="$(jq -r .why <<<"$row")" record="$(jq -c .record <<<"$row")" + # The desk's issue as "id: title", or the bare id, so an address reads as a job. + named="$(jq -r '(.issue // "") as $i | (.issue_title // "") as $t + | if $i != "" and $t != "" then "\($i): \($t)" else $i end' <<<"$record")" + tag="${named:+ ($named)}" case "$action" in resume) - what="$(jq -r '[.issue, .session, .cwd] | map(select(. != "")) | join(" ")' <<<"$record")" + what="$(jq -r --arg named "$named" '[$named, .session, .cwd] | map(select(. != "")) | join(" ")' <<<"$record")" if [[ "$dry" -eq 1 ]]; then printf '%-6s would resume %s\n' "$address" "$what" DESK_RESUMED=$((DESK_RESUMED + 1)) @@ -232,9 +236,9 @@ _desk_restore_pass() { why="$(jq -r .why <<<"$confirm")" if [[ "$action" == wait ]]; then DESK_WAITING=$((DESK_WAITING + 1)) - printf '%-6s waiting: %s\n' "$address" "$why" + printf '%-6s waiting: %s%s\n' "$address" "$why" "$tag" else - printf '%-6s skip: %s\n' "$address" "$why" + printf '%-6s skip: %s%s\n' "$address" "$why" "$tag" fi elif _desk_hc send "$address" "$(desk_resume_command "$record")" >/dev/null; then DESK_TYPED["$address"]=1 @@ -248,12 +252,12 @@ _desk_restore_pass() { ;; wait) DESK_WAITING=$((DESK_WAITING + 1)) - printf '%-6s waiting: %s\n' "$address" "$why" + printf '%-6s waiting: %s%s\n' "$address" "$why" "$tag" ;; gone) [[ "$dry" -eq 1 ]] || rm -f "$(_desk_sessions_dir)/$address.json" ;; - *) printf '%-6s skip: %s\n' "$address" "$why" ;; + *) printf '%-6s skip: %s%s\n' "$address" "$why" "$tag" ;; esac done < <(jq -c '.[]' <<<"$plan") } diff --git a/lib/doctor.sh b/lib/doctor.sh index 55c9b6d..f2fb764 100644 --- a/lib/doctor.sh +++ b/lib/doctor.sh @@ -87,7 +87,7 @@ doctor_herdr_app_update() { # started, later than the last transition the follower recorded, is one it # didn't see. A follower without a record runs a bercail older than this check. doctor_notify_missed() { - local f started last address ms + local f started last address ms term issue title f="$(notify_follower_file)" started="$(jq -r '.started_at // empty' "$f" 2>/dev/null)" if [[ ! "$started" =~ ^[0-9]+$ ]]; then @@ -98,12 +98,20 @@ doctor_notify_missed() { [[ "$last" =~ ^[0-9]+$ ]] || last=0 while IFS= read -r address; do [[ -n "$address" ]] || continue - ms="$("$(notify_hc)" get "$address" --json 2>/dev/null </dev/null \ - | jq -r 'select(.status.state == "done" or .status.state == "blocked") | .status_ms // empty' 2>/dev/null)" + term="$("$(notify_hc)" get "$address" --json 2>/dev/null </dev/null)" || continue + ms="$(jq -r 'select(.status.state == "done" or .status.state == "blocked") | .status_ms // empty' <<<"$term" 2>/dev/null)" || true [[ "$ms" =~ ^[0-9]+$ ]] || continue # Slack: the follower records a transition a moment after hc stamps it. if (( ms / 1000 > started + 5 && ms / 1000 > last + 60 )); then - printf 'missed desk %s finishing or blocking' "$address" + issue="$(jq -r '.labels["bercail.issue"] // empty' <<<"$term" 2>/dev/null)" || issue="" + title="$(jq -r '.labels["bercail.issue_title"] // empty' <<<"$term" 2>/dev/null)" || title="" + if [[ -n "$issue" && -n "$title" ]]; then + printf 'missed desk %s (%s: %s) finishing or blocking' "$address" "$issue" "$title" + elif [[ -n "$issue" ]]; then + printf 'missed desk %s (%s) finishing or blocking' "$address" "$issue" + else + printf 'missed desk %s finishing or blocking' "$address" + fi return 1 fi done < <("$(notify_hc)" ls --json --label bercail.role=agent 2>/dev/null </dev/null \ diff --git a/lib/notify.sh b/lib/notify.sh index c128981..46c7fc9 100644 --- a/lib/notify.sh +++ b/lib/notify.sh @@ -189,7 +189,7 @@ notify_desk_info() { 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), + issue:($h.issue // null), issue_title:($h.issue_title // null), stage:($h.stage // null), harness:($h.harness // (if $agent == "" then null else $agent end)), result:$r}' } @@ -288,8 +288,22 @@ notify_payload() { 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 agent "$agent_json" --argjson space "$space_json" --argjson desk "$desk_json" ' + def titled: if type == "string" and endswith(".") then .[:-1] else . end; + def where: + "Desk " + ($agent.address // "") + + (if ($space.name // "") == "" then "" else " (" + $space.name + ")" end); + def line: + (($desk.issue_title // "") | titled) as $title + | if $kind == "test" then "Test from bercail (sink " + $sink + ")." + else + (if ($desk.issue // "") == "" then "" + elif $title == "" then $desk.issue + ". " + else $desk.issue + ": " + $title + (if $title | endswith("…") then " " else ". " end) end) + + where + + (if $kind == "blocked" then " is blocked." else " finished." end) + end; {source:"bercail", schema:$schema, kind:$kind, sink:$sink, at:$at, - event_id:$event_id, agent:$agent, space:$space, desk:$desk}' + event_id:$event_id, agent:$agent, space:$space, desk:$desk, text:line}' } notify_deliver_all() { @@ -528,7 +542,7 @@ notify_test() { [[ -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,"stage":null,"harness":null,"result":null}' + 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)")")" case "$code" in nourl|insecure) _notify_err "sink '$name': $code"; return 1 ;; diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index 25315a8..e824d4b 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -45,8 +45,8 @@ Auto-review rejects those as unbound executable content. Shep writes the job as a beads issue first (`bd create`, `bd show <id>`). The issue holds the ask, the repo, links, and any plan-only note. There are no prompt files, briefs, or pending prompts. The agent's whole prompt is one line -pointing at the issue (`Your job is beads issue <id>. Run bd show <id> …`), the -same for claude, codex, and cursor-agent. Shep owns the workflow +pointing at the issue (`Your job is beads issue <id>: <title>. Run bd show <id> …`, +or the id alone when bd has no title), the same for claude, codex, and cursor-agent. Shep owns the workflow (planning, review, which harness); bercail has no cursor-only mode. Each real handoff adds a `bd comment` to the issue with what the agent needs @@ -135,15 +135,15 @@ working directory to `cwd` from `--info`. Flags only. space on it (worktrunk's post-start hook), and starts that space's desk agent: an hc terminal running the named stage's binary (claude, codex, cursor-agent, or opencode) with a one-line prompt pointing at the issue as its first message, labelled -`bercail.role=agent` and `bercail.space=<space id>`. The bercail layout shows +`bercail.role=agent`, `bercail.space=<space id>`, and, when known, `bercail.issue` and `bercail.issue_title`. The bercail layout shows it in the desk column. `--stage` is required; bercail has no default harness. A missing binary fails before any worktree is made. A desk already running in that space is replaced. `wt remove <branch>` closes the space and its desk. ## After a `--branch` spawn -The script prints JSON: `ok`, `label`, `path`, `branch`, `issue`, -`agent_started`, `dirty_copied`, `graphite`, `stage`, `harness`, `space` (the +The script prints JSON: `ok`, `label`, `path`, `branch`, `issue`, `issue_title` +(when known), `agent_started`, `dirty_copied`, `graphite`, `stage`, `harness`, `space` (the child's Herdr app space) and `desk` (the desk agent's hc address). It appends that line to `~/.local/state/agentic-dev/handoffs.jsonl` whenever the sibling exists. diff --git a/skills/handoff/resources/handoff.md b/skills/handoff/resources/handoff.md index 7ec8c4d..1997aca 100644 --- a/skills/handoff/resources/handoff.md +++ b/skills/handoff/resources/handoff.md @@ -23,16 +23,18 @@ run `--info` then spawn — not assemble these steps. Keep the parent relation in that backend; publishing uses its stack commands. 5. Add the intro (worktree; stage, don't commit; Review opens on agent done; selected git backend and stack commands) to the beads issue as a `bd comment`. The prompt is one line - pointing at the issue, the same for every binary. No prompt file and no + pointing at the issue (`<id>: <title>`, or the id alone when bd has no title), the same for every binary. No prompt file and no per-harness mode. 6. `WT_HERDR_AGENT_CMD` is the stage table's binary and `WT_HERDR_AGENT_PROMPT` - that one-line prompt. `wt_herdr_handoff_agent` starts the space's desk: + that one-line prompt. `WT_HERDR_ISSUE` and `WT_HERDR_ISSUE_TITLE` name the issue. + `wt_herdr_handoff_agent` starts the space's desk: `hc run --bg --keep --outlive-parent --parent <space's first terminal>` of `bercail-desk-agent --prompt <prompt> <binary>` with labels - `bercail.role=agent` and `bercail.space=<space id>`, replacing a live desk + `bercail.role=agent`, `bercail.space=<space id>`, and, when set, `bercail.issue` + and `bercail.issue_title`, replacing a live desk in that space. A failed start does **not** discard the worktree. -7. Print JSON `{ok,label,path,branch,issue,agent_started,dirty_copied,graphite,stage,harness,space,desk}` - and append `~/.local/state/agentic-dev/handoffs.jsonl` whenever the sibling +7. Print JSON `{ok,label,path,branch,issue,issue_title,agent_started,dirty_copied,graphite,stage,harness,space,desk}` + (`issue_title` omitted when unknown) and append `~/.local/state/agentic-dev/handoffs.jsonl` whenever the sibling exists. Callers in no hc terminal (`HC_TERMINAL_ID` unset) run the **resolved script diff --git a/skills/handoff/scripts/handoff-spawn b/skills/handoff/scripts/handoff-spawn index 23200ee..c94ac81 100755 --- a/skills/handoff/scripts/handoff-spawn +++ b/skills/handoff/scripts/handoff-spawn @@ -25,7 +25,7 @@ usage: handoff-spawn --info --info print JSON facts and exit --issue ID beads issue to work (bd show ID). It holds the detail. The - agent's whole prompt is the id, for every harness. + agent's whole prompt is the id and its short title, for every harness. --stage NAME required; named stage (see handoff-stages.sh). start is claude, codex is Codex CLI, cursor is cursor-agent, bstack is opencode (agent bstack, the lead's model), autopilot is opencode's @@ -381,7 +381,7 @@ 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:-}" space="${10:-}" desk="${11:-}" + local stage="${8:-}" harness="${9:-}" space="${10:-}" desk="${11:-}" issue_title="${12:-}" # stage/harness: what the desk agent runs; space/desk: where it runs (hc address). jq -nc \ --arg label "$label" \ @@ -395,11 +395,13 @@ handoff_result_json() { --arg harness "$harness" \ --arg space "$space" \ --arg desk "$desk" \ + --arg issue_title "$issue_title" \ '{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) + (if $space == "" then {} else {space:$space} end) - + (if $desk == "" then {} else {desk:$desk} end)' + + (if $desk == "" then {} else {desk:$desk} end) + + (if $issue_title == "" then {} else {issue_title:$issue_title} end)' } handoff_info_json() { @@ -473,7 +475,7 @@ handoff_info_json() { } handoff_spawn_main() { - local branch="" dirty="auto" issue="" prompt main sibling label intro graphite=0 copied=0 backend=gh + local branch="" dirty="auto" issue="" issue_title="" prompt main sibling label intro graphite=0 copied=0 backend=gh local helper="${HOME:-}/.config/worktrunk/herdr-layout.sh" local json keep_space info=0 started=0 space="" desk="" local stage="" worktree="" resume="" dry=0 mode="" binary="" binary_override="" desk_bin="" @@ -583,6 +585,7 @@ handoff_spawn_main() { main="$(handoff_realpath "$PWD")" handoff_issue_exists "$main" "$issue" || handoff_die "beads issue $issue not found (bd show $issue)" + issue_title="$(handoff_issue_title "$main" "$issue")" handoff_herdr_socket_ok \ || handoff_die "cannot reach the Herdr app (herdr-app status failed); open it first" @@ -663,13 +666,16 @@ handoff_spawn_main() { fi handoff_issue_comment "$sibling" "$issue" "$intro" - prompt="$(handoff_issue_prompt "$issue")" + prompt="$(handoff_issue_prompt "$issue" "$issue_title")" - # The desk gets the table binary through WT_HERDR_AGENT_CMD and the one-line - # prompt (pointing at the issue) through WT_HERDR_AGENT_PROMPT. No file. + # The desk gets the table binary through WT_HERDR_AGENT_CMD, the one-line + # prompt through WT_HERDR_AGENT_PROMPT, and the issue through WT_HERDR_ISSUE + # and WT_HERDR_ISSUE_TITLE. No file. if WT_HERDR_KEEP_FOCUS="$keep_space" \ WT_HERDR_AGENT_CMD="$desk_bin" \ WT_HERDR_AGENT_PROMPT="$prompt" \ + WT_HERDR_ISSUE="$issue" \ + WT_HERDR_ISSUE_TITLE="$issue_title" \ wt_herdr_handoff_agent "$label" "$sibling"; then started=1 else @@ -678,7 +684,7 @@ handoff_spawn_main() { space="$(_wt_herdr_space_by_folder "$sibling" 2>/dev/null || true)" [[ -z "$space" ]] || desk="$(_wt_herdr_space_desk "$space" 2>/dev/null || true)" - json="$(handoff_result_json "$label" "$sibling" "$branch" "$issue" "$started" "$copied" "$graphite" "$stage" "$binary" "$space" "$desk")" + json="$(handoff_result_json "$label" "$sibling" "$branch" "$issue" "$started" "$copied" "$graphite" "$stage" "$binary" "$space" "$desk" "$issue_title")" [[ -z "$base" ]] || json="$(jq -c --arg base "$base" '. + {base:$base}' <<<"$json")" handoff_notify_check "$sibling" handoff_remember "$json" diff --git a/skills/handoff/scripts/handoff-stages.sh b/skills/handoff/scripts/handoff-stages.sh index bd3a1de..a99fb5a 100644 --- a/skills/handoff/scripts/handoff-stages.sh +++ b/skills/handoff/scripts/handoff-stages.sh @@ -123,11 +123,36 @@ handoff_issue_comment() { || printf 'handoff: could not add the handoff comment to %s\n' "$id" >&2 } +# Past this, a title is the first max-1 characters, right-trimmed, plus "…". +HANDOFF_TITLE_MAX=60 + +# Title of beads issue ID from DIR. Empty, and 0, on any failure (missing bd, +# unknown issue, bad JSON). Whitespace and control runs become one space. +handoff_issue_title() { + local dir="$1" id="$2" raw="" title="" + raw="$(cd "$dir" && "$(handoff_bd_bin)" show "$id" --json 2>/dev/null)" || return 0 + title="$(jq -r --argjson max "$HANDOFF_TITLE_MAX" ' + (if type == "array" then .[0] else . end).title // "" + | gsub("[[:space:][:cntrl:]]+"; " ") + | gsub("^ +| +$"; "") + | if length > $max then (.[0:($max - 1)] | gsub(" +$"; "")) + "…" else . end + ' <<<"$raw" 2>/dev/null)" || return 0 + printf '%s' "$title" +} + # The whole agent prompt, the same for every harness: one line pointing at the -# beads issue (a bare id makes agents ask what it is instead of reading it). +# beads issue. TITLE, when set, is named after the id; its own "." or a +# truncation's "…" ends the clause. A bare id stays the old sentence. # Shep owns the workflow; bercail adds no per-binary mode. handoff_issue_prompt() { - printf 'Your job is beads issue %s. Run `bd show %s` (read its comments too) and do the work it describes.' "$1" "$1" + local id="$1" title="${2:-}" stop="." + title="${title%.}" + [[ "$title" != *… ]] || stop="" + if [[ -n "$title" ]]; then + printf 'Your job is beads issue %s: %s%s Run `bd show %s` (read its comments too) and do the work it describes.' "$id" "$title" "$stop" "$id" + else + printf 'Your job is beads issue %s. Run `bd show %s` (read its comments too) and do the work it describes.' "$id" "$id" + fi } # reason is optional. Empty omits the field; Shep reads it, and unknown fields @@ -144,6 +169,16 @@ handoff_write_result() { + if $reason == "" then {} else {reason:$reason} end' >"$dest" } +# issue, and issue_title when known, on a result file. Other fields stay. +handoff_stamp_issue() { + local dest="$1" issue="$2" title="${3:-}" stamped + [[ -f "$dest" ]] || return 0 + stamped="$(jq -c --arg issue "$issue" --arg issue_title "$title" ' + . + {issue:$issue} + (if $issue_title == "" then {} else {issue_title:$issue_title} end) + ' "$dest" 2>/dev/null)" || return 0 + printf '%s\n' "$stamped" >"$dest" +} + # verification is optional: [{kind, command, exit_code, evidence}]. A statechart # entry may carry spec and chart in place of command and evidence (bercail-86a). HANDOFF_VERIFICATION_DEF=' @@ -536,7 +571,7 @@ handoff_headless_comment_text() { handoff_run_headless() { local stage="$1" worktree="$2" issue="$3" resume="$4" dry="$5" binary_override="${6:-}" local mode binary native model_spec model session="" resumed=0 prompt bd beads_dir="" - local result log bin cmd_json out rc=0 tid present=false bd_present=false reason="" + local result log bin cmd_json out rc=0 tid present=false bd_present=false reason="" issue_title="" command -v jq >/dev/null 2>&1 || handoff_die "jq is required" mode="$(handoff_stage_field "$stage" mode)" || handoff_die "unknown stage: $stage" @@ -596,6 +631,7 @@ handoff_run_headless() { if [[ -n "$beads_dir" && "$beads_dir/" == "$worktree/"* ]]; then beads_dir="" fi + issue_title="$(handoff_issue_title "$worktree" "$issue")" fi bin="$(handoff_headless_bin "$binary")" @@ -603,24 +639,27 @@ handoff_run_headless() { if [[ "$bd_present" != true ]]; then [[ -n "$session" ]] || session="$HANDOFF_NO_SESSION" handoff_block "$result" "bd (beads) is not on PATH. The job is a beads issue; install beads (bercail update installs it)." "$session" || true + handoff_stamp_issue "$result" "$issue" "$issue_title" cat -- "$result"; printf '\n' return 1 fi if ! handoff_issue_exists "$worktree" "$issue"; then [[ -n "$session" ]] || session="$HANDOFF_NO_SESSION" handoff_block "$result" "beads issue ${issue} not found from ${worktree} (bd show ${issue})." "$session" || true + handoff_stamp_issue "$result" "$issue" "$issue_title" cat -- "$result"; printf '\n' return 1 fi if ! handoff_bin_present "$bin"; then [[ -n "$session" ]] || session="$HANDOFF_NO_SESSION" handoff_block_missing_harness "$result" "$binary" "$session" || true + handoff_stamp_issue "$result" "$issue" "$issue_title" cat -- "$result"; printf '\n' return 1 fi fi - prompt="$(handoff_issue_prompt "$issue")" + prompt="$(handoff_issue_prompt "$issue" "$issue_title")" local agent=bstack [[ "$stage" == autopilot ]] && agent=bstack-autopilot handoff_build_headless_argv "$binary" "$bin" "$model" "$session" "$resumed" "$prompt" "$beads_dir" "$agent" @@ -644,7 +683,9 @@ handoff_run_headless() { --argjson present "$present" \ --argjson bd_present "$bd_present" \ --argjson command "$cmd_json" \ - '{ok:true,stage:$stage,mode:$mode,binary:$binary,model:$model,worktree:$worktree,issue:$issue,result:$result,session_id:$session_id,resume:$resume,dry_run:$dry_run,present:$present,bd_present:$bd_present,command:$command}')" + --arg issue_title "$issue_title" \ + '{ok:true,stage:$stage,mode:$mode,binary:$binary,model:$model,worktree:$worktree,issue:$issue,result:$result,session_id:$session_id,resume:$resume,dry_run:$dry_run,present:$present,bd_present:$bd_present,command:$command} + + (if $issue_title == "" then {} else {issue_title:$issue_title} end)')" if [[ "$dry" -eq 1 ]]; then printf '%s\n' "$out" @@ -703,6 +744,7 @@ handoff_run_headless() { [[ -n "$session" ]] || session="$HANDOFF_NO_SESSION" handoff_write_result "$result" failed "headless stage exited ${rc} without a result file" "$session" "$reason" fi + handoff_stamp_issue "$result" "$issue" "$issue_title" if [[ "$rc" -ne 0 ]]; then printf 'handoff: %s exited %s; result is %s\n' "$bin" "$rc" "$result" >&2 return "$rc" diff --git a/tests/desk_agent_test.sh b/tests/desk_agent_test.sh index e140cc3..adaa187 100755 --- a/tests/desk_agent_test.sh +++ b/tests/desk_agent_test.sh @@ -113,8 +113,8 @@ export HC_TERMINAL_ID=t9 got="$(desk --prompt 'Your job is beads issue bercail-0uo. Run it.' opencode --verbose)" [[ "$got" == "opencode [--verbose] [--prompt] [Your job is beads issue bercail-0uo. Run it.] cli="* ]] \ || fail "the argv a desk runs does not change: $got" -expect "$(record '[.address, .session, .harness, .issue, .space, .args]')" \ - '["t9","","opencode","bercail-0uo","sp1",["--verbose"]]' "a record, with the prompt out of its flags" +expect "$(record '[.address, .session, .harness, .issue, .issue_title, .space, .args]')" \ + '["t9","","opencode","bercail-0uo","","sp1",["--verbose"]]' "a record, with the prompt out of its flags" expect "$(record .cwd | jq -r .)" "$PWD" "the record has the folder" printf 'PASS: a desk writes its record and its command stays the same\n' @@ -125,6 +125,16 @@ desk claude --resume abc >/dev/null expect "$(record '[.session, .harness]')" '["abc","claude"]' "claude's resume flag" printf 'PASS: a resume keeps session, flags and issue\n' +printf '{"labels":{"bercail.role":"agent","bercail.space":"sp1","bercail.issue":"bercail-0uo","bercail.issue_title":"Short title"}}\n' >"$TMP_DIR/terminal.json" +desk --prompt 'Your job is beads issue bercail-0uo: Short title. Run it.' opencode --verbose >/dev/null +expect "$(record .issue_title)" '"Short title"' "title comes from the label when it names the issue" +printf '{"labels":{"bercail.role":"agent","bercail.space":"sp1","bercail.issue":"other-1","bercail.issue_title":"Wrong"}}\n' >"$TMP_DIR/terminal.json" +desk opencode --session ses_kept --verbose >/dev/null +expect "$(record '[.issue, .issue_title, .session]')" '["bercail-0uo","Short title","ses_kept"]' \ + "resume keeps the title when the label names a different issue" +printf '{"labels":{"bercail.role":"agent","bercail.space":"sp1"}}\n' >"$TMP_DIR/terminal.json" +printf 'PASS: the desk record takes issue_title from the label, and a resume keeps it\n' + # Resume replays args beside an explicit session, so a prompt or "continue the # latest" must not be in them. The command that ran is unchanged. got="$(desk opencode --verbose --prompt 'Do not replay')" diff --git a/tests/desk_restore_test.sh b/tests/desk_restore_test.sh index 40f02ec..ee780e7 100644 --- a/tests/desk_restore_test.sh +++ b/tests/desk_restore_test.sh @@ -249,6 +249,20 @@ expect "$(sort "$SENT" | sed 's/^\(t[0-9]*\) .*exec [^ ]* /\1 /')" $'t1 pi --ses [[ "$out" == *"2 came back"* && "$out" != *t3* ]] || fail "siblings are not counted or waited for: $out" printf 'PASS: a Pi desk and an opencode desk come back together\n' +reset_pass +terminal t1 "$bare" +put_record t1 ses_titled 60 +jq '.issue_title = "Fix login"' "$RECORDS/t1.json" >"$RECORDS/t1.json.new" +mv "$RECORDS/t1.json.new" "$RECORDS/t1.json" +out="$(cmd_desk restore --dry-run)" +[[ "$out" == *"t1 would resume x-1: Fix login ses_titled $TMP_DIR"* ]] \ + || fail "resume line should name the title: $out" +terminal t1 "$(jq -c '.foreground = {name: "opencode", pid: 99}' <<<"$bare")" +out="$(cmd_desk restore --dry-run)" +[[ "$out" == *"t1 skip: already running (x-1: Fix login)"* ]] \ + || fail "skip line should name the issue: $out" +printf 'PASS: a desk is listed as id: title, resumed or skipped\n' + touch "$TMP_DIR/hc-down" if out="$(cmd_desk restore 2>&1)"; then fail "hc down should fail: $out"; fi [[ -f "$RECORDS/t1.json" ]] || fail "hc down must not read as terminals gone" diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index 91ef57e..c69dfe9 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -310,6 +310,10 @@ test_usage() { test_issue_prompt_points_at_the_issue() { [[ "$(handoff_issue_prompt bercail-0s1)" == 'Your job is beads issue bercail-0s1. Run `bd show bercail-0s1` (read its comments too) and do the work it describes.' ]] \ || fail "prompt points at the issue: $(handoff_issue_prompt bercail-0s1)" + [[ "$(handoff_issue_prompt bercail-0s1 'Show the title')" == 'Your job is beads issue bercail-0s1: Show the title. Run `bd show bercail-0s1` (read its comments too) and do the work it describes.' ]] \ + || fail "prompt with a title: $(handoff_issue_prompt bercail-0s1 'Show the title')" + [[ "$(handoff_issue_prompt bercail-0s1 'Done.')" == 'Your job is beads issue bercail-0s1: Done. Run `bd show bercail-0s1` (read its comments too) and do the work it describes.' ]] \ + || fail "a trailing period on the title must not read as two: $(handoff_issue_prompt bercail-0s1 'Done.')" handoff_issue_id_ok bercail-0s1 || fail "plain beads id" handoff_issue_id_ok bercail-0s1.2 || fail "child beads id" handoff_issue_id_ok --help && fail "flag is not an id" @@ -421,7 +425,10 @@ 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" + printf '%s' "$got" | jq -e '(has("stage")|not) and (has("harness")|not) and (has("issue_title")|not)' >/dev/null || fail "no stage: $got" + got="$(handoff_result_json "Lbl" "/tmp/wt" "br" "bercail-0s1" 1 0 0 start claude "" "" "Show the title")" + printf '%s' "$got" | jq -e '.issue_title == "Show the title" and .stage == "start" and .harness == "claude" and (has("space")|not)' >/dev/null \ + || fail "issue_title: $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' @@ -608,7 +615,11 @@ set -euo pipefail case "$1" in show) [[ " ${BD_KNOWN:-} " == *" $2 "* ]] || { echo "no issue $2" >&2; exit 1; } - printf '[{"id":"%s"}]\n' "$2" + if [[ -n "${BD_TITLE:-}" ]]; then + jq -nc --arg id "$2" --arg title "$BD_TITLE" '[{id:$id, title:$title}]' + else + jq -nc --arg id "$2" '[{id:$id}]' + fi ;; comment) [[ "${3:-}" == --stdin ]] || exit 3 @@ -1733,3 +1744,190 @@ test_gh_base_inits_a_new_stack_and_refuses_a_middle_parent test_gh_base_adds_from_the_parent_worktree test_origin_base_tracks_locally test_spawn_gh_base_does_not_call_gt + +test_issue_title_normalises_and_degrades() { + local wt="$TMP_DIR/title-norm" got err long want messy + mkdir -p "$wt" + _bd_stub + long="$(printf '%70s' '' | tr ' ' 'x')" + want="${long:0:59}"$'…' + got="$(BD_TITLE="$long" handoff_issue_title "$wt" "$ISSUE")" || fail "title lookup failed" + [[ "$got" == "$want" ]] || fail "70-char title should be 60 with an ellipsis: ${#got} [$got]" + [[ "$(handoff_issue_prompt "$ISSUE" "$got")" == "Your job is beads issue $ISSUE: $want Run \`bd show $ISSUE\` (read its comments too) and do the work it describes." ]] \ + || fail "truncated title in the prompt: $(handoff_issue_prompt "$ISSUE" "$got")" + + messy="$(printf 'say "hi" \\ path\n\tand')" + got="$(BD_TITLE="$messy" handoff_issue_title "$wt" "$ISSUE")" || fail "messy title lookup failed" + [[ "$got" == 'say "hi" \ path and' ]] || fail "normalised title: [$got]" + if printf '%s' "$got" | grep -q '[[:cntrl:]]'; then fail "title must be one line: [$got]"; fi + + err="$(HANDOFF_BD_BIN="$TMP_DIR/no-such-bd" handoff_issue_title "$wt" "$ISSUE" 2>&1)" || fail "missing bd must not fail the lookup" + [[ -z "$err" ]] || fail "missing bd should print nothing: $err" + err="$(BD_KNOWN=other handoff_issue_title "$wt" "$ISSUE" 2>&1)" || fail "unknown issue must not fail the lookup" + [[ -z "$err" ]] || fail "unknown issue should print nothing: $err" + printf 'PASS: a title is truncated, one line, and a missing bd or issue is a bare id\n' +} + +test_issue_title_on_headless_runs() { + local wt="$TMP_DIR/title-head" out stub="$TMP_DIR/bin/title-claude" rc=0 + local title='Show the title' messy long want + mkdir -p "$TMP_DIR/bin" + _start_fixture "$wt" + messy="$(printf 'say "hi" \\ path\n\tand')" + out="$(cd "$wt" && BD_TITLE="$messy" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" --dry-run 2>"$TMP_DIR/title-dry.err")" \ + || fail "dry-run with a title failed: $out $(cat "$TMP_DIR/title-dry.err")" + [[ ! -s "$TMP_DIR/title-dry.err" ]] || fail "title lookup must not write stderr: $(cat "$TMP_DIR/title-dry.err")" + printf '%s' "$out" | jq -e --arg id "$ISSUE" --arg t 'say "hi" \ path and' ' + .issue_title == $t + and (.command[-1] | test("\n|\t") | not) + and .command[-1] == ("Your job is beads issue " + $id + ": " + $t + ". Run `bd show " + $id + "` (read its comments too) and do the work it describes.") + ' >/dev/null || fail "dry-run prompt/json: $out" + [[ ! -f "$wt/.bercail/handoff-result.json" ]] || fail "dry-run must not write a result" + + long="$(printf '%70s' '' | tr ' ' 'x')" + want="${long:0:59}"$'\u2026' + out="$(cd "$wt" && BD_TITLE="$long" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" --dry-run)" + printf '%s' "$out" | jq -e --arg t "$want" '.issue_title == $t and (.command[-1] | contains($t))' >/dev/null \ + || fail "dry-run should carry the truncated title: $out" + + out="$(cd "$wt" && BD_KNOWN=other \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" --dry-run 2>"$TMP_DIR/title-unk.err")" \ + || fail "unknown issue dry-run must not fail: $(cat "$TMP_DIR/title-unk.err")" + printf '%s' "$out" | jq -e --arg id "$ISSUE" '(has("issue_title") | not) and (.command[-1] | startswith("Your job is beads issue " + $id + "."))' >/dev/null \ + || fail "unknown issue stays a bare id: $out" + [[ ! -s "$TMP_DIR/title-unk.err" ]] || fail "unknown issue dry-run stderr: $(cat "$TMP_DIR/title-unk.err")" + + cat >"$stub" <<'STUB' +#!/bin/sh +jq -nc '{status:"ready", summary:"done the work"}' >"$BERCAIL_RESULT_PATH" +STUB + chmod +x "$stub" + out="$(cd "$wt" && BD_TITLE="$title" HANDOFF_CLAUDE_BIN="$stub" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt")" \ + || fail "titled run failed: $out" + printf '%s' "$out" | jq -e --arg t "$title" --arg id "$ISSUE" '.issue == $id and .issue_title == $t' >/dev/null \ + || fail "out json: $out" + jq -e --arg t "$title" --arg id "$ISSUE" \ + '.status == "ready" and .issue == $id and .issue_title == $t' \ + "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "result file: $(cat "$wt/.bercail/handoff-result.json")" + handoff_result_ok "$wt/.bercail/handoff-result.json" || fail "stamped result must still pass handoff_result_ok" + + rm -f "$wt/.bercail/handoff-result.json" + rc=0 + out="$(cd "$wt" && BD_TITLE="$title" HANDOFF_CLAUDE_BIN="$TMP_DIR/no-such-claude" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" 2>/dev/null)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "missing harness should still block" + jq -e --arg t "$title" --arg id "$ISSUE" \ + '.status == "blocked" and .issue == $id and .issue_title == $t' \ + "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "blocked result should carry the title: $(cat "$wt/.bercail/handoff-result.json")" + + rc=0 + out="$(cd "$wt" && HANDOFF_CLAUDE_BIN="$stub" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue bercail-nope --worktree "$wt" 2>/dev/null)" || rc=$? + [[ "$rc" -ne 0 ]] || fail "unknown issue should block" + jq -e '.status == "blocked" and .issue == "bercail-nope" and (has("issue_title") | not)' \ + "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "unknown issue is a bare id: $(cat "$wt/.bercail/handoff-result.json")" + + rc=0 + out="$(cd "$wt" && HANDOFF_BD_BIN="$TMP_DIR/no-such-bd" HANDOFF_CLAUDE_BIN="$stub" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" 2>"$TMP_DIR/title-bd.err")" || rc=$? + [[ "$rc" -ne 0 ]] || fail "missing bd should block" + jq -e --arg id "$ISSUE" '.status == "blocked" and .issue == $id and (has("issue_title") | not)' \ + "$wt/.bercail/handoff-result.json" >/dev/null \ + || fail "missing bd is a bare id: $(cat "$wt/.bercail/handoff-result.json")" + grep -q 'jq:' "$TMP_DIR/title-bd.err" && fail "missing bd must not surface a title error: $(cat "$TMP_DIR/title-bd.err")" + + out="$(cd "$wt" && HANDOFF_BD_BIN="$TMP_DIR/no-such-bd" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --stage start --issue "$ISSUE" --worktree "$wt" --dry-run 2>"$TMP_DIR/title-bd-dry.err")" \ + || fail "dry-run with missing bd failed: $(cat "$TMP_DIR/title-bd-dry.err")" + printf '%s' "$out" | jq -e --arg id "$ISSUE" '(has("issue_title") | not) and (.command[-1] | startswith("Your job is beads issue " + $id + "."))' >/dev/null \ + || fail "missing bd dry-run should keep the bare prompt: $out" + [[ ! -s "$TMP_DIR/title-bd-dry.err" ]] || fail "missing bd dry-run stderr: $(cat "$TMP_DIR/title-bd-dry.err")" + unset HANDOFF_CLAUDE_BIN BERCAIL_CLAUDE_MODEL_CATALOG + printf 'PASS: headless JSON and the result file carry the title, or the bare id\n' +} + +test_branch_carries_issue_title() { + local repo="$TMP_DIR/title-branch" bin="$TMP_DIR/title-branch-bin" home="$TMP_DIR/title-branch-home" + local state="$TMP_DIR/title-branch-state" out title='Say "hi"' + local folder="$TMP_DIR/title-folder" hclog="$TMP_DIR/title-hc.log" argv="$TMP_DIR/title-hc.argv" + git_init "$repo" + mkdir -p "$bin" "$home/.config/worktrunk" + cp "$ROOT/config/worktrunk/herdr-layout.sh" "$home/.config/worktrunk/herdr-layout.sh" + cat >"$bin/wt" <<'WT' +#!/usr/bin/env bash +branch="" +while [[ $# -gt 0 ]]; do + case "$1" in + --create) branch="$2"; shift 2 ;; + *) shift ;; + esac +done +main="$(git worktree list --porcelain | awk '/^worktree / { print substr($0, 10); exit }')" +git -C "$main" worktree add -q -b "$branch" "$main.$branch" HEAD +printf '%s' "$(cd "$main.$branch" && pwd -P)" >"$TITLE_FOLDER" +WT + cat >"$bin/herdr-app" <<'HERDR' +#!/usr/bin/env bash +case "$1 ${2:-}" in + "status ") printf '{"space":"sp-keep"}\n' ;; + "ls ") + f="" + [[ -f "$TITLE_FOLDER" ]] && f="$(cat "$TITLE_FOLDER")" + if [[ -n "$f" ]]; then + jq -nc --arg f "$f" '{spaces:[{id:"sp1",name:"Lbl",folder:$f,tabs:[{terminals:[{terminal:"t9"}]}]}]}' + else + printf '{"spaces":[]}\n' + fi + ;; + "do space.new") + printf '%s' "$6" >"$TITLE_FOLDER" + printf '{"space":"sp1"}\n' + ;; + *) printf '{"ok":true}\n' ;; +esac +HERDR + cat >"$bin/hc" <<'HC' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$TITLE_HC_LOG" +if [[ "$1" == run ]]; then + printf '%s\0' "$@" | jq -Rs 'split("\u0000") | .[:-1]' >"$TITLE_HC_ARGV" +fi +case "$1" in + ls) printf '{"terminals":[]}\n' ;; + *) printf '{}\n' ;; +esac +HC + printf '#!/bin/sh\nexit 0\n' >"$bin/claude" + chmod +x "$bin/wt" "$bin/herdr-app" "$bin/hc" "$bin/claude" + _bd_stub + : >"$BD_COMMENTS" + : >"$hclog" + export TITLE_FOLDER="$folder" TITLE_HC_LOG="$hclog" TITLE_HC_ARGV="$argv" + out="$(cd "$repo" && BD_TITLE="$title" HOME="$home" PATH="$bin:$PATH" \ + HERDR_APP_BIN="$bin/herdr-app" HC_BIN="$bin/hc" \ + XDG_STATE_HOME="$state" BERCAIL_BIN="$TMP_DIR/no-bercail" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" \ + --branch feat --stage start --issue "$ISSUE" --clean 2>"$TMP_DIR/title-branch.err")" \ + || fail "branch spawn failed: $out $(cat "$TMP_DIR/title-branch.err")" + printf '%s' "$out" | jq -e --arg t "$title" --arg id "$ISSUE" \ + '.issue == $id and .issue_title == $t and .agent_started == true' >/dev/null \ + || fail "branch json: $out $(cat "$TMP_DIR/title-branch.err")" + jq -e --arg t "$title" --arg id "$ISSUE" '.issue == $id and .issue_title == $t' \ + "$state/agentic-dev/handoffs.jsonl" >/dev/null \ + || fail "handoffs.jsonl: $(cat "$state/agentic-dev/handoffs.jsonl")" + jq -e --arg issue "bercail.issue=$ISSUE" --arg titled "bercail.issue_title=$title" \ + 'index($issue) and index($titled)' "$argv" >/dev/null \ + || fail "hc run labels: $(cat "$argv" 2>/dev/null) log=$(cat "$hclog")" + unset TITLE_FOLDER TITLE_HC_LOG TITLE_HC_ARGV + printf 'PASS: a --branch spawn puts the title in JSON, handoffs.jsonl, and the desk labels\n' +} + +test_issue_title_normalises_and_degrades +test_issue_title_on_headless_runs +test_branch_carries_issue_title diff --git a/tests/herdr_layout_worktree_test.sh b/tests/herdr_layout_worktree_test.sh index e5cb2f6..60c241e 100644 --- a/tests/herdr_layout_worktree_test.sh +++ b/tests/herdr_layout_worktree_test.sh @@ -137,11 +137,26 @@ if WT_HERDR_AGENT_CMD=claude wt_herdr_handoff_agent "Main_Feature" "$TMP_DIR/mai fi printf 'PASS: handoff requires a prompt\n' +: >"$CALL_LOG" +WT_HERDR_AGENT_CMD=claude WT_HERDR_AGENT_PROMPT='Your job is beads issue bercail-0s1: Fix login.' \ + WT_HERDR_ISSUE=bercail-0s1 WT_HERDR_ISSUE_TITLE='Fix login' \ + wt_herdr_handoff_agent "Main_Feature" "$TMP_DIR/main.feature" +grep -q -- '--label bercail.issue=bercail-0s1' "$CALL_LOG" || fail "issue label missing: $(cat "$CALL_LOG")" +grep -q -- '--label bercail.issue_title=Fix login' "$CALL_LOG" || fail "title label missing: $(cat "$CALL_LOG")" +: >"$CALL_LOG" +WT_HERDR_AGENT_CMD=claude WT_HERDR_AGENT_PROMPT=x \ + wt_herdr_handoff_agent "Main_Feature" "$TMP_DIR/main.feature" +grep -q 'bercail.issue' "$CALL_LOG" && fail "no issue env, no issue label: $(cat "$CALL_LOG")" : >"$CALL_LOG" WT_HERDR_AGENT_PROMPT=ignored wt_herdr_start_default_agent "Main_Feature" "$TMP_DIR/main.feature" grep -qx "$RUN" "$CALL_LOG" \ || fail "default agent is bercail-desk-agent's pick, without a prompt; log=$(cat "$CALL_LOG")" -printf 'PASS: default agent is bercail-desk-agent with no prompt\n' +: >"$CALL_LOG" +WT_HERDR_ISSUE=bercail-0s1 WT_HERDR_ISSUE_TITLE='Fix login' WT_HERDR_AGENT_PROMPT=ignored \ + wt_herdr_start_default_agent "Main_Feature" "$TMP_DIR/main.feature" +grep -q 'bercail.issue' "$CALL_LOG" && fail "default agent must not inherit issue labels: $(cat "$CALL_LOG")" +grep -qx "$RUN" "$CALL_LOG" || fail "default agent argv unchanged: $(cat "$CALL_LOG")" +printf 'PASS: default agent is bercail-desk-agent with no prompt; issue labels only when set\n' cp "$FAKE_STATE/spaces.json" "$FAKE_STATE/spaces.bak" jq 'map(if .id == "sp-child" then .tabs = [] else . end)' "$FAKE_STATE/spaces.bak" >"$FAKE_STATE/spaces.json" diff --git a/tests/notify_test.sh b/tests/notify_test.sh index ac03943..60569ee 100644 --- a/tests/notify_test.sh +++ b/tests/notify_test.sh @@ -57,7 +57,7 @@ git -C "$TMP_DIR/myapp" commit -q --allow-empty -m init git -C "$TMP_DIR/myapp" worktree add -q -b feat "$TMP_DIR/myapp.feat" desk="$TMP_DIR/myapp.feat" mkdir -p "$XDG_STATE_HOME/agentic-dev" -jq -nc --arg p "$desk" '{path:$p, branch:"feat", issue:"myapp-1", stage:"start", harness:"claude"}' \ +jq -nc --arg p "$desk" '{path:$p, branch:"feat", issue:"myapp-1", issue_title:"Fix the login", stage:"start", harness:"claude"}' \ >"$XDG_STATE_HOME/agentic-dev/handoffs.jsonl" # One `hc events` line: every terminal, the desk with STATUS. @@ -81,8 +81,11 @@ jq -e --arg p "$desk" ' and .agent.address == "t9" and .agent.status == "done" and .agent.previous_status == "working" and .agent.app == "claude-code" and .agent.title == "Beads issue myapp-1" and .space.id == "sp1" and .space.repo == "myapp" and .space.checkout_path == $p and .space.linked == true - and .desk.issue == "myapp-1" and .desk.stage == "start" and .desk.branch == "feat"' <<<"$first" >/dev/null \ + and .desk.issue == "myapp-1" and .desk.issue_title == "Fix the login" and .desk.stage == "start" and .desk.branch == "feat" + and .text == "myapp-1: Fix the login. Desk t9 finished."' <<<"$first" >/dev/null \ || fail "payload: $first" +jq -se '.[1].kind == "blocked" and .[1].text == "myapp-1: Fix the login. Desk t9 is blocked." and .[1].desk.issue_title == "Fix the login"' \ + "$TMP_DIR/bodies.jsonl" >/dev/null || fail "blocked text: $(sed -n 2p "$TMP_DIR/bodies.jsonl")" grep -q 'Authorization: Bearer secret-key' "$TMP_DIR/headers.txt" || fail "bearer header" grep -q 'X-Automation-Key: secret-key' "$TMP_DIR/headers.txt" || fail "automation key header" grep -q secret-key "$TMP_DIR/bodies.jsonl" && fail "the key must not be in the body" @@ -183,9 +186,35 @@ printf 'PASS: a rejected key is recorded and status prints no url or key\n' out="$(notify_test work-shep)" || fail "test send: $out" [[ "$out" == "work-shep HTTP 200" ]] || fail "test output: $out" -jq -e 'select(.kind == "test")' "$TMP_DIR/bodies.jsonl" >/dev/null || fail "test payload" +jq -e 'select(.kind == "test") | .desk.issue_title == null and .text == "Test from bercail (sink work-shep)."' \ + "$TMP_DIR/bodies.jsonl" >/dev/null || fail "test payload: $(jq -c 'select(.kind == "test")' "$TMP_DIR/bodies.jsonl")" printf 'PASS: notify test sends a kind=test payload\n' +agent='{"address":"t9","app":"claude","status":"done","previous_status":"working","title":null}' +space='{"id":"sp1","name":"Readable-titles_Bercail","repo":"bercail","checkout_path":"/w","linked":true}' +noname='{"id":"sp1","name":null,"repo":"bercail","checkout_path":"/w","linked":true}' +desk_t='{"branch":"readable-titles","issue":"bercail-wb1","issue_title":"Show the issue title next to every bd and desk id","stage":"start","harness":"claude","result":null}' +notitle='{"branch":"readable-titles","issue":"bercail-wb1","issue_title":null,"stage":"start","harness":"claude","result":null}' +noissue='{"branch":null,"issue":null,"issue_title":null,"stage":null,"harness":null,"result":null}' +dotted='{"branch":"readable-titles","issue":"bercail-wb1","issue_title":"Show the title.","stage":"start","harness":"claude","result":null}' +payload_text() { notify_payload "$1" work-shep "$agent" "$2" "$3" e1 | jq -r .text; } +[[ "$(payload_text finished "$space" "$desk_t")" == "bercail-wb1: Show the issue title next to every bd and desk id. Desk t9 (Readable-titles_Bercail) finished." ]] \ + || fail "finished text: $(payload_text finished "$space" "$desk_t")" +[[ "$(payload_text blocked "$space" "$desk_t")" == "bercail-wb1: Show the issue title next to every bd and desk id. Desk t9 (Readable-titles_Bercail) is blocked." ]] \ + || fail "blocked text: $(payload_text blocked "$space" "$desk_t")" +[[ "$(payload_text finished "$space" "$noissue")" == "Desk t9 (Readable-titles_Bercail) finished." ]] \ + || fail "no issue text: $(payload_text finished "$space" "$noissue")" +[[ "$(payload_text finished "$space" "$notitle")" == "bercail-wb1. Desk t9 (Readable-titles_Bercail) finished." ]] \ + || fail "bare id text: $(payload_text finished "$space" "$notitle")" +[[ "$(payload_text finished "$noname" "$desk_t")" == "bercail-wb1: Show the issue title next to every bd and desk id. Desk t9 finished." ]] \ + || fail "no space name: $(payload_text finished "$noname" "$desk_t")" +[[ "$(payload_text finished "$space" "$dotted")" == "bercail-wb1: Show the title. Desk t9 (Readable-titles_Bercail) finished." ]] \ + || fail "trailing period: $(payload_text finished "$space" "$dotted")" +cut='{"branch":"readable-titles","issue":"bercail-wb1","issue_title":"Show the issue…","stage":"start","harness":"claude","result":null}' +[[ "$(payload_text finished "$space" "$cut")" == "bercail-wb1: Show the issue… Desk t9 (Readable-titles_Bercail) finished." ]] \ + || fail "truncated title: $(payload_text finished "$space" "$cut")" +printf 'PASS: payload text names the issue and the desk\n' + # doctor shows when the live follower last posted, without the url or key. # shellcheck source=lib/doctor.sh source "$ROOT/lib/doctor.sh" @@ -219,7 +248,27 @@ EOF chmod +x "$TMP_DIR/bin/hc-desk" printf '{"pid":1,"started_at":%s}\n' "$((now - 600))" >"$(notify_follower_file)" out="$(HC_BIN="$TMP_DIR/bin/hc-desk" doctor_notify)" && fail "a deaf follower passed doctor: $out" -printf '%s' "$out" | grep -q 'deaf notify follower is live but missed desk t3s' || fail "deaf: $out" +printf '%s' "$out" | grep -q 'deaf notify follower is live but missed desk t3s finishing or blocking' || fail "deaf: $out" +cat >"$TMP_DIR/bin/hc-titled" <<'EOF' +#!/usr/bin/env bash +case "$1" in + ls) printf '{"terminals":[{"address":"t3s","state":"live"}]}\n' ;; + get) + if [[ "$2" == t3s ]]; then + jq -nc --argjson ms "$HC_STATUS_MS" --arg title "${HC_ISSUE_TITLE:-}" ' + {address:"t3s", state:"live", status:{state:"done"}, status_ms:$ms, + labels: ({"bercail.issue":"bercail-wb1"} + (if $title == "" then {} else {"bercail.issue_title":$title} end))}' + else + printf '{"state":"live"}\n' + fi + ;; +esac +EOF +chmod +x "$TMP_DIR/bin/hc-titled" +out="$(HC_BIN="$TMP_DIR/bin/hc-titled" HC_STATUS_MS="$(( (now - 30) * 1000 ))" HC_ISSUE_TITLE='Show the title' doctor_notify)" && fail "titled miss passed: $out" +printf '%s' "$out" | grep -q 'missed desk t3s (bercail-wb1: Show the title) finishing or blocking' || fail "titled deaf: $out" +out="$(HC_BIN="$TMP_DIR/bin/hc-titled" HC_STATUS_MS="$(( (now - 30) * 1000 ))" HC_ISSUE_TITLE='' doctor_notify)" && fail "bare id miss passed: $out" +printf '%s' "$out" | grep -q 'missed desk t3s (bercail-wb1) finishing or blocking' || fail "bare id deaf: $out" printf '{"pid":1,"started_at":%s,"last_transition_at":%s}\n' "$((now - 600))" "$((now - 29))" >"$(notify_follower_file)" out="$(HC_BIN="$TMP_DIR/bin/hc-desk" doctor_notify)" || fail "a follower that saw it: $out" printf '{"pid":1,"started_at":%s}\n' "$((now - 10))" >"$(notify_follower_file)"