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
6 changes: 6 additions & 0 deletions config/agentic-dev/config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ effort = "high"
lead = "claude"
build = "grok"
judge = "claude"

# [graphite]
# Handoff detects Graphite by the branches gt tracks. Override it per repo, keyed
# by the main checkout's path: true for a Graphite repo with no stack right now, false
# for a repo where gt once ran but you don't use it.
# "~/Work/some-repo" = false
5 changes: 4 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ effort = "high"
lead = "claude"
build = "grok"
judge = "claude"

[graphite] # optional, one line per repo; see below
"~/Work/some-repo" = false
```

After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md).
After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md); `[graphite]` in [handoff](handoff.md#graphite).

## Files bercail manages

Expand Down
18 changes: 17 additions & 1 deletion docs/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot)
~/.agents/skills/handoff/scripts/handoff-spawn --branch NAME --stage cursor --issue ID [--dirty|--clean] [--space ID] [--base BRANCH]
```

`--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 (Graphite, when the repo uses it, tracks BRANCH as the parent). It works only with `--branch`, and stage `autopilot` can't take `--branch`: give it `--worktree` on the PR's checkout.
`--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 ([Graphite](#graphite), when the repo uses it, tracks BRANCH as the parent). 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.``

Expand All @@ -39,3 +39,19 @@ 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.

## graphite

With `--branch`, a repo that uses Graphite gets `gt track` on the new branch and a line in the agent's handoff comment telling it to use `gt`. `--info` and the spawn JSON report it as `graphite`.

Any `gt` command initializes the repo it runs in, so a stray one must not turn this on. A repo counts as Graphite when `gt` tracks a branch other than trunk. A repo that only has Graphite's config file doesn't count. The spawn prints a note because a real Graphite repo looks the same once its last stack is merged. The check reads `.graphite_metadata.db` with `sqlite3`. Without `sqlite3`, or if the file can't be read, the spawn skips Graphite and warns. Install `sqlite3` or use the override below.

To decide for a repo yourself, add its main checkout to `~/.config/bercail/config.toml`. Linked worktrees follow it.

```toml
[graphite]
"~/Work/leftlane_app" = true # a Graphite repo with no stack right now
"~/Work/bercail" = false # gt once ran here, but you don't use it
```

Tracking can't stop a spawn. Each `gt` call is killed after 30 seconds (`HANDOFF_GT_TIMEOUT`). If `--base` is untracked, bercail tracks it on trunk and retries, then falls back to trunk. On any other failure, such as a locked Graphite database, it changes nothing, so your stack stays as it was. If tracking still fails, or `gt` hangs, the spawn prints a warning with the `gt track` command to run, and the desk still starts.
6 changes: 4 additions & 2 deletions skills/handoff/resources/git-workflow.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Project git / Graphite / GitHub (handoff)

Spawn (`scripts/handoff-spawn --info` then spawn) already reports `graphite`
and Graphite-tracks the sibling when that is true. Do not re-detect. The child
intro already says to use `gt` on Graphite repos.
and Graphite-tracks the sibling when that is true. Do not re-detect, and do not
run `gt` to check: any `gt` command auto-initializes a plain repo. The child
intro already says to use `gt` on Graphite repos. How detection works and how to
override it: [handoff.md](handoff.md#graphite).

Handoff never auto-commits. Agents stage their work (`git add`) and leave it
uncommitted: the staged diff is what the human reviews in tuicr (`tuicr -w`,
Expand Down
35 changes: 32 additions & 3 deletions skills/handoff/resources/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ run `--info` then spawn — not assemble these steps.
3. If `--dirty` or `default_copy` is dirty (and not `--clean`): copy
`git diff HEAD` plus untracked files into the sibling as a **working tree**.
Never `git add`.
4. If Graphite config exists (`handoff_graphite_config`, in the shared git
dir), `gt track` a branch that has no parent yet.
4. If the repo really uses Graphite (`handoff_is_graphite`, see below), `gt
track` a branch that has no parent yet. A gt problem never stops the spawn.
5. Add the intro (worktree; stage, don't commit; Review opens on agent done;
gt on Graphite) to the beads issue as a `bd comment`. The prompt is one line
gt when Graphite is on) 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
per-harness mode.
6. `WT_HERDR_AGENT_CMD` is the stage table's binary and `WT_HERDR_AGENT_PROMPT`
Expand Down Expand Up @@ -98,3 +98,32 @@ Result: sibling `git diff --cached` empty; dirty files are unstaged/untracked.
The script is the only spawn path. Callers must not `hc send` or type into a
desk, start a desk with `hc run` themselves, or switch the user to the child's
space.

## Graphite

Any `gt` command auto-initializes the repo it runs in, so the config file in the
shared git dir (`.graphite_repo_config`) proves nothing. `handoff_is_graphite`
decides in this order:

1. An entry for the main checkout under `[graphite]` in bercail's config.toml
(`"~/Work/repo" = true` or `false`). Linked worktrees use their main
checkout's entry.
2. No config file: not Graphite.
3. gt tracks a branch other than trunk: a `refs/branch-metadata/*` ref (older
gt) or a row with a parent in `.graphite_metadata.db` (current gt, read with
`sqlite3 -readonly`). Auto-init only writes rows with no parent.
4. A store that can't be read (no sqlite3, busy, unknown schema) skips Graphite,
with a warning that names the override. Install sqlite3 or set the override.

A config with nothing tracked is off, with a note that names the override: a
real repo looks the same as a stray one once its last stack is merged. An entry
in `[graphite]` silences both.

`handoff_track_graphite` runs every `gt` call through `handoff_gt`: its own
process group, no stdin, stdout sent to stderr (stdout is the spawn's JSON), and
killed with its children after `HANDOFF_GT_TIMEOUT` seconds (default 30). It tries the parent
(`--base`, else trunk). Only when `gt branch info` says the parent is untracked
or unknown does it track that parent on trunk and retry, then fall back to trunk.
On any other failure (a tracked parent, a locked store) it changes nothing else:
re-tracking would rewrite a real stack. It never passes `--force`. A failure or a hung gt warns and the desk still
starts; after a hang it runs no more gt.
170 changes: 157 additions & 13 deletions skills/handoff/scripts/handoff-spawn
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,72 @@ handoff_graphite_config() {
printf '%s' "$cfg"
}

# This repo's entry in [graphite] of bercail's config.toml: "<main checkout>" =
# true|false. A linked worktree goes by its main checkout. Fails with no entry.
handoff_graphite_override() {
local config="${BERCAIL_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/bercail/config.toml}" main key value
[[ -f "$config" ]] || return 1
main="$(handoff_git_main "$1")"
[[ -n "$main" ]] || return 1
main="$(handoff_realpath "$main")"
while IFS=$'\t' read -r key value; do
[[ "$key" != \~/* ]] || key="$HOME/${key#\~/}"
if [[ "$(handoff_realpath "$key")" == "$main" ]]; then
printf '%s' "$value"
return 0
fi
done < <(awk '
/^[[:space:]]*\[/ { sec = $0; sub(/#.*/, "", sec); gsub(/[[:space:]"\047]/, "", sec); next }
sec != "[graphite]" { next }
{
line = $0
sub(/^[[:space:]]*/, "", line)
q = substr(line, 1, 1)
if (q != "\"" && q != "\047") next
line = substr(line, 2)
i = index(line, q)
if (!i) next
value = substr(line, i + 1)
sub(/^[[:space:]]*=[[:space:]]*/, "", value)
sub(/[[:space:]]*(#.*)?$/, "", value)
if (value == "true" || value == "false") print substr(line, 1, i - 1) "\t" value
}' "$config")
return 1
}

# Any gt command auto-inits a repo. Only non-trunk refs (older gt) or parented
# rows in .graphite_metadata.db (current gt) prove branches are tracked.
handoff_graphite_tracks_branches() {
local root="$1" cfg="$2" trunks refs db rows
trunks="$(jq -r '[.trunk, .trunks[]?.name] | map(select(. != null)) | .[]' "$cfg" 2>/dev/null || true)"
refs="$(git -C "$root" for-each-ref --format='%(refname:lstrip=2)' refs/branch-metadata/ 2>/dev/null || true)"
if [[ -n "$refs" ]] && grep -qvxF -e "$trunks" <<<"$refs"; then
return 0
fi
db="$(dirname "$cfg")/.graphite_metadata.db"
if [[ -f "$db" ]]; then
if ! rows="$("${HANDOFF_SQLITE3_BIN:-sqlite3}" -readonly "$db" \
"select 1 from branch_metadata where coalesce(parent_branch_name, '') <> '' limit 1" 2>/dev/null)"; then
printf 'handoff-spawn: cannot read %s (sqlite3 missing, database busy, or unknown schema); skipping Graphite. Set "%s" = true or false under [graphite] in bercail config.toml to override.\n' \
"$db" "$(handoff_git_main "$root")" >&2
return 1
fi
[[ -z "$rows" ]] || return 0
fi
# A real repo looks the same once its last stack is merged.
printf 'handoff-spawn: gt has a config here but tracks no branch, so not using Graphite. If this repo uses it, set "%s" = true under [graphite] in bercail config.toml.\n' \
"$(handoff_git_main "$root")" >&2
return 1
}

handoff_is_graphite() {
handoff_graphite_config "$1" >/dev/null
local override cfg
if override="$(handoff_graphite_override "$1")"; then
[[ "$override" == true ]]
return
fi
cfg="$(handoff_graphite_config "$1")" || return 1
handoff_graphite_tracks_branches "$1" "$cfg"
}

# Copy main's uncommitted tracked+untracked files into sibling as a working
Expand All @@ -113,23 +177,103 @@ handoff_copy_dirty() {
done < <(git -C "$main" ls-files --others --exclude-standard)
}

# Run a command in its own process group, so its children die with it. Status
# 124 when it outlives $1 seconds. Not timeout(1): macOS has none.
handoff_bounded() {
local ticks=$(($1 * 10)) pid rc
shift
(
# Job control only for the fork: it is what gives the command its own group.
# Left on, bash also prints a notice ("[1]+ Done") when the command ends.
set -m
"$@" &
pid=$!
set +m
# gt left the terminal's group with set -m, so Ctrl-C only reaches it from here.
# Die by the signal: on a plain exit 130 the caller would carry on to the next gt.
trap 'kill -KILL -- "-$pid" 2>/dev/null; trap - INT; kill -INT 0' INT
trap 'kill -KILL -- "-$pid" 2>/dev/null; exit 143' TERM
while kill -0 "$pid" 2>/dev/null; do
if [[ "$ticks" -le 0 ]]; then
kill -TERM -- "-$pid" 2>/dev/null || true
sleep 1
kill -KILL -- "-$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
exit 124
fi
ticks=$((ticks - 1))
sleep 0.1
done
rc=0
wait "$pid" || rc=$?
# A child left behind would hold the caller's pipes open.
kill -KILL -- "-$pid" 2>/dev/null || true
exit "$rc"
)
}

handoff_gt_timeout() {
local secs="${HANDOFF_GT_TIMEOUT:-30}"
[[ "$secs" =~ ^[1-9][0-9]*$ ]] || secs=30
printf '%s' "$secs"
}

# No gt call can hold the spawn or answer a prompt, and stdout is the spawn's JSON.
handoff_gt() {
local sibling="$1"
shift
handoff_bounded "$(handoff_gt_timeout)" gt --cwd "$sibling" "$@" --no-interactive </dev/null >&2
}

# Repair only when gt says the parent is untracked or unknown. Other errors
# (such as a locked database) must never re-parent an existing stack.
handoff_gt_track() {
local sibling="$1" parent="$2" trunk="$3" rc=0 info
handoff_gt "$sibling" track --parent "$parent" || rc=$?
[[ "$rc" -ne 0 && "$rc" -ne 124 && "$parent" != "$trunk" ]] || return "$rc"
rc=0
info="$(handoff_gt "$sibling" branch info --branch "$parent" 2>&1)" || rc=$?
[[ "$rc" -ne 124 ]] || return 124
[[ "$rc" -ne 0 && ( "$info" == *"untracked branch"* || "$info" == *"Could not find branch"* ) ]] || return 1
rc=0
handoff_gt "$sibling" track "$parent" --parent "$trunk" || rc=$?
[[ "$rc" -ne 124 ]] || return 124
if [[ "$rc" -eq 0 ]]; then
rc=0
handoff_gt "$sibling" track --parent "$parent" || rc=$?
[[ "$rc" -ne 0 ]] || return 0
[[ "$rc" -ne 124 ]] || return 124
fi
printf 'handoff-spawn: gt could not track %s as the parent; using trunk %s.\n' "$parent" "$trunk" >&2
handoff_gt "$sibling" track --parent "$trunk"
}

# parent: the branch the new one was cut from (--base); default is trunk.
# Never fails the spawn: the worktree and its space exist by now, so a gt problem
# is a warning and the desk still starts.
handoff_track_graphite() {
local sibling="$1" parent="${2:-}" cfg trunk
cfg="$(handoff_graphite_config "$sibling")" || return 0
command -v gt >/dev/null 2>&1 || return 0
if gt --cwd "$sibling" parent >/dev/null 2>&1; then
local sibling="$1" parent="${2:-}" cfg trunk rc=0
# gt would auto-init here, on a trunk it guessed.
if ! cfg="$(handoff_graphite_config "$sibling")"; then
printf 'handoff-spawn: Graphite is on for this repo but it has no gt config; not tracking %s. Run gt init, then gt track.\n' "$sibling" >&2
return 0
fi
if [[ -n "$parent" ]]; then
trunk="$parent"
else
trunk="$(jq -r '.trunk // "main"' "$cfg" 2>/dev/null || true)"
trunk="${trunk:-main}"
command -v gt >/dev/null 2>&1 || return 0
handoff_gt "$sibling" parent >/dev/null 2>&1 || rc=$?
[[ "$rc" -ne 0 ]] || return 0
trunk="$(jq -r '.trunk // .trunks[0].name // "main"' "$cfg" 2>/dev/null || true)"
trunk="${trunk:-main}"
parent="${parent:-$trunk}"
if [[ "$rc" -ne 124 ]]; then
rc=0
handoff_gt_track "$sibling" "$parent" "$trunk" || rc=$?
fi
gt --cwd "$sibling" track --parent "$trunk" --no-interactive \
|| gt --cwd "$sibling" track --force --no-interactive \
|| handoff_die "gt track failed for $sibling"
case "$rc" in
0) return 0 ;;
124) printf 'handoff-spawn: gt did not finish in %ss; ' "$(handoff_gt_timeout)" >&2 ;;
*) printf 'handoff-spawn: gt failed; ' >&2 ;;
esac
printf 'the desk starts anyway, untracked. In %s run: gt track --parent %s\n' "$sibling" "$parent" >&2
}

handoff_sibling_path() {
Expand Down
Loading
Loading