---
name: Handover
slug: handover-2
category: Automation
description: Handover appends dated, structured entries to a per-repo RepoHandover.md and installs a global Stop hook to offer a handoff when a session ends. Use it to recover archived-session context without grepping.
github: "https://github.com/jazzyalex/agent-sessions/tree/main/docs/superpowers/plans/2026-07-09-handover-skill.md"
language: Swift
stars: 806
forks: 52
install: "npx degit https://github.com/jazzyalex/agent-sessions/tree/main/docs/superpowers/plans ~/.claude/skills/plans"
installs_to: ~/.claude/skills/plans
source_path: docs/superpowers/plans/2026-07-09-handover-skill.md
collection_size: 8
category_size: 1523
collection_url: "https://dirskills.com/collections/jazzyalex/agent-sessions"
added: 2026-08-22T05:22:14.845Z
last_synced: 2026-08-22T05:22:14.845Z
canonical_url: "https://dirskills.com/skills/handover-2"
---

# Handover

Handover appends dated, structured entries to a per-repo RepoHandover.md and installs a global Stop hook to offer a handoff when a session ends. Use it to recover archived-session context without grepping.

**Install:**

```bash
npx degit https://github.com/jazzyalex/agent-sessions/tree/main/docs/superpowers/plans ~/.claude/skills/plans
```

## README

# Handover Skill Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a global `/handover` skill + a global Stop hook that append dated, structured entries to a per-repo `RepoHandover.md`, so archived-session context is recoverable without grepping.

**Architecture:** Develop three testable shell artifacts + one skill markdown in the repo under `tools/handover/` (source of truth, versioned, TDD-tested following the existing `tools/test_*.sh` pattern), then an idempotent `install.sh` deploys them to the global `~/.claude/` (skill → `~/.claude/skills/handover/`, hook → `~/.claude/hooks/`, Stop-hook wiring merged into `~/.claude/settings.json`). "Test in `tools/handover/` first, install to `~/.claude/` last" honors the repo's test-before-production rule.

**Tech Stack:** Bash (`#!/usr/bin/env bash`, `set -euo pipefail`), `jq` (`/usr/bin/jq`), Claude Code skills + hooks (`Stop` event), plain-shell test scripts.

## Global Constraints

- Spec: `docs/superpowers/specs/2026-07-09-handover-skill-design.md` (authoritative for format + behavior).
- Test style: plain `bash` scripts under `tools/`, named `tools/test_handover_*.sh`, using `set -euo pipefail` and `pass()`/`fail()` helpers with `PASSED`/`FAILED` counters (match `tools/test_smoke.sh`).
- All hermetic tests must NOT touch the real `~/.claude/` — override `HOME` and `TMPDIR` to temp dirs.
- The skill and hook **never run `git commit`** — they write files only; the user commits (repo rule).
- Entry key block is exactly: line 1 `## <DATE> <TIME> · <slug> · <title>`, line 2 `status: <value>`, line 3 `branch: <text>`. `status` ∈ `in-progress | blocked | done | superseded-by:<YYYY-MM-DD>`. Not YAML.
- Newest-first: new entries are **prepended** to `RepoHandover.md`.
- Stop hook offers **at most once per session** (session-id sentinel), soft offer only (never `decision:"block"`).
- Commit messages: Conventional Commits, no "Generated with Claude Code" footer, no Co-Authored-By. Trailers `Tool:`/`Model:`/`Why:` optional per repo convention. Do NOT commit unless the executing operator confirms (repo rule); plan commit steps stage only their own task's paths.

---

## File Structure

Source of truth (repo, versioned, tested):
- Create `tools/handover/handover-lint.sh` — validates the newest entry's key block. Test oracle + reusable by the skill.
- Create `tools/handover/handover-offer.sh` — the `Stop` hook: gates + soft offer.
- Create `tools/handover/install.sh` — idempotent global installer.
- Create `tools/handover/SKILL.md` — the `/handover` skill (drafting/format/write/supersede/pointer logic).
- Create `tools/test_handover_lint.sh` — tests for the validator.
- Create `tools/test_handover_hook.sh` — tests for the hook (gate matrix).
- Create `tools/test_handover_install.sh` — tests for the installer (fake `HOME`).

Deployed (global, created by installer at execution time):
- `~/.claude/skills/handover/SKILL.md`
- `~/.claude/hooks/handover-offer.sh`
- `~/.claude/settings.json` (`Stop` hook entry merged in)

---

## Task 1: Format validator (`handover-lint.sh`)

Locks the entry-format contract as executable spec. The validator checks the **newest** (topmost) entry's 3-line key block. Later tasks (the skill) use it as an acceptance oracle.

**Files:**
- Create: `tools/handover/handover-lint.sh`
- Test: `tools/test_handover_lint.sh`

**Interfaces:**
- Consumes: nothing.
- Produces: CLI `handover-lint.sh <path-to-RepoHandover.md>` → exit `0` if the topmost entry's key block is valid, exit `1` + a one-line reason on stderr otherwise. Empty/missing file → exit `1`.

- [ ] **Step 1: Write the failing test**

Create `tools/test_handover_lint.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

LINT="$(dirname "$0")/handover/handover-lint.sh"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
PASSED=0; FAILED=0
pass() { echo "✓ $1"; PASSED=$((PASSED+1)); }
fail() { echo "✗ $1"; FAILED=$((FAILED+1)); }

# assert_exit <expected-code> <file> <label>
assert_exit() {
  local want="$1" file="$2" label="$3" got=0
  bash "$LINT" "$file" >/dev/null 2>&1 || got=$?
  if [ "$got" = "$want" ]; then pass "$label"; else fail "$label (want exit $want, got $got)"; fi
}

# Valid entry
cat > "$WORK/good.md" <<'EOF'
## 2026-07-09 14:32 · runway-auth · AS-owned OAuth (P2)
status: in-progress
branch: main @ 9ade2753 (dirty: 2 files)

**State in one line:** next is P2.
EOF
assert_exit 0 "$WORK/good.md" "valid entry passes"

# superseded-by status is valid
cat > "$WORK/superseded.md" <<'EOF'
## 2026-07-09 14:32 · runway-auth · title
status: superseded-by:2026-07-10
branch: main @ abc1234 (clean)
EOF
assert_exit 0 "$WORK/superseded.md" "superseded-by status passes"

# Bad status value
cat > "$WORK/badstatus.md" <<'EOF'
## 2026-07-09 14:32 · slug · title
status: wip
branch: main @ abc1234
EOF
assert_exit 1 "$WORK/badstatus.md" "invalid status fails"

# Missing branch line
cat > "$WORK/nobranch.md" <<'EOF'
## 2026-07-09 14:32 · slug · title
status: done
**State:** x
EOF
assert_exit 1 "$WORK/nobranch.md" "missing branch line fails"

# Heading without timestamp
cat > "$WORK/badhead.md" <<'EOF'
## runway-auth notes
status: done
branch: main
EOF
assert_exit 1 "$WORK/badhead.md" "heading without timestamp fails"

# Empty file
: > "$WORK/empty.md"
assert_exit 1 "$WORK/empty.md" "empty file fails"

# Missing file
assert_exit 1 "$WORK/does-not-exist.md" "missing file fails"

echo "----"; echo "PASSED=$PASSED FAILED=$FAILED"
[ "$FAILED" = 0 ]
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `bash tools/test_handover_lint.sh`
Expected: FAIL — script errors because `tools/handover/handover-lint.sh` does not exist yet.

- [ ] **Step 3: Write the validator**

Create `tools/handover/handover-lint.sh`:

```bash
#!/usr/bin/env bash
# Validate the newest (topmost) entry's key block in a RepoHandover.md.
# Usage: handover-lint.sh <path>   ->   exit 0 valid, exit 1 invalid (reason on stderr)
set -euo pipefail

file="${1:-}"
if [ -z "$file" ] || [ ! -f "$file" ]; then
  echo "handover-lint: file not found: ${file:-<none>}" >&2
  exit 1
fi

# Find the first heading line and the two lines after it.
head_line="$(grep -nE '^## ' "$file" | head -1 || true)"
if [ -z "$head_line" ]; then
  echo "handover-lint: no '## ' entry heading found" >&2
  exit 1
fi
hn="${head_line%%:*}"                    # line number of first heading
h="$(sed -n "${hn}p" "$file")"
s="$(sed -n "$((hn+1))p" "$file")"
b="$(sed -n "$((hn+2))p" "$file")"

# Heading: "## DATE TIME · slug · title"
if ! printf '%s' "$h" | grep -qE '^## [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2} · .+ · .+'; then
  echo "handover-lint: heading not in '## DATE TIME · slug · title' form: $h" >&2
  exit 1
fi
# status line
if ! printf '%s' "$s" | grep -qE '^status: (in-progress|blocked|done|superseded-by:[0-9]{4}-[0-9]{2}-[0-9]{2})[[:space:]]*(#.*)?$'; then
  echo "handover-lint: bad or missing status line: $s" >&2
  exit 1
fi
# branch line (non-empty payload)
if ! printf '%s' "$b" | grep -qE '^branch: .+'; then
  echo "handover-lint: bad or missing branch line: $b" >&2
  exit 1
fi
exit 0
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `chmod +x tools/handover/handover-lint.sh && bash tools/test_handover_lint.sh`
Expected: PASS — final line `PASSED=7 FAILED=0`, exit 0.

- [ ] **Step 5: Commit**

```bash
git add tools/handover/handover-lint.sh tools/test_handover_lint.sh
git commit -m "feat(handover): entry-format validator + tests"
```

---

## Task 2: Stop hook (`handover-offer.sh`)

The gated soft-offer hook. Reads the hook JSON on stdin, applies gates, and — only when all pass — prints the offer JSON and marks a once-per-session sentinel.

**Files:**
- Create: `tools/handover/handover-offer.sh`
- Test: `tools/test_handover_hook.sh`

**Interfaces:**
- Consumes: stdin JSON with fields `stop_hook_active` (bool), `session_id` (string), `cwd` (string), `transcript_path` (string). (Field names per hook spec.)
- Produces: on all-gates-pass, prints to stdout either
  `{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"…"}}` (default) or
  `{"systemMessage":"…"}` when `HANDOVER_OFFER_MODE=systemMessage`; and creates sentinel `${TMPDIR:-/tmp}/claude-handover-<session_id>`. Always exits `0`. On any gate fail, prints nothing, exits `0`.
- Tunables (env): `HANDOVER_MIN_TRANSCRIPT_LINES` (default `50`), `HANDOVER_OFFER_MODE` (`context`|`systemMessage`, default `context`).

- [ ] **Step 1: Write the failing test**

Create `tools/test_handover_hook.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

HOOK="$(dirname "$0")/handover/handover-offer.sh"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR"
PASSED=0; FAILED=0
pass() { echo "✓ $1"; PASSED=$((PASSED+1)); }
fail() { echo "✗ $1"; FAILED=$((FAILED+1)); }

# make_input <session_id> <cwd> <transcript> <stop_active>
make_input() {
  jq -n --arg s "$1" --arg c "$2" --arg t "$3" --argjson a "$4" \
    '{session_id:$s, cwd:$c, transcript_path:$t, stop_hook_active:$a}'
}

# A substantive transcript (>= 50 lines) and a trivial one
BIG="$WORK/big.jsonl";  for i in $(seq 1 60); do echo "{\"i\":$i}"; done > "$BIG"
SMALL="$WORK/small.jsonl"; echo '{"i":1}' > "$SMALL"
REPO="$WORK/repo"; mkdir -p "$REPO"   # not a git repo; substantiveness comes from transcript size

# 1. all gates pass -> emits additionalContext offer
out="$(make_input sess1 "$REPO" "$BIG" false | HANDOVER_OFFER_MODE=context bash "$HOOK")"
if echo "$out" | jq -e '.hookSpecificOutput.additionalContext | test("handover")' >/dev/null 2>&1; then
  pass "substantive session emits additionalContext offer"; else fail "expected offer, got: $out"; fi

# 2. sentinel now exists -> second call is silent
out="$(make_input sess1 "$REPO" "$BIG" false | bash "$HOOK")"
[ -z "$out" ] && pass "once-per-session: second call silent" || fail "second call should be silent, got: $out"

# 3. stop_hook_active=true -> silent (loop guard), fresh session
out="$(make_input sess2 "$REPO" "$BIG" true | bash "$HOOK")"
[ -z "$out" ] && pass "loop guard: stop_hook_active silent" || fail "loop guard failed, got: $out"

# 4. trivial session (short transcript, no git changes) -> silent
out="$(make_input sess3 "$REPO" "$SMALL" false | bash "$HOOK")"
[ -z "$out" ] && pass "trivial session stays silent" || fail "trivial should be silent, got: $out"

# 5. systemMessage mode
out="$(make_input sess4 "$REPO" "$BIG" false | HANDOVER_OFFER_MODE=systemMessage bash "$HOOK")"
if echo "$out" | jq -e '.systemMessage | test("handover")' >/dev/null 2>&1; then
  pass "systemMessage mode emits systemMessage"; else fail "expected systemMessage, got: $out"; fi

# 6. git-dirty short session -> offers (git signal ORs in)
GITREPO="$WORK/gitrepo"; mkdir -p "$GITREPO"
git -C "$GITREPO" init -q && echo x > "$GITREPO/f.txt"   # untracked change = dirty
out="$(make_input sess5 "$GITREPO" "$SMALL" false | bash "$HOOK")"
if echo "$out" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then
  pass "git-dirty short session offers"; else fail "git-dirty should offer, got: $out"; fi

# 7. always exits 0 even on gate fail
make_input sess6 "$REPO" "$SMALL" false | bash "$HOOK" >/dev/null 2>&1
[ $? = 0 ] && pass "exits 0 on silent path" || fail "should exit 0"

echo "----"; echo "PASSED=$PASSED FAILED=$FAILED"
[ "$FAILED" = 0 ]
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `bash tools/test_handover_hook.sh`
Expected: FAIL — `tools/handover/handover-offer.sh` does not exist yet.

- [ ] **Step 3: Write the hook**

Create `tools/handover/handover-offer.sh`:

```bash
#!/usr/bin/env bash
# Claude Code Stop hook: offer a handover once per substantive session.
# Reads hook JSON on stdin; prints a soft offer (stdout JSON) at most once/session.
# Never blocks, never writes RepoHandover.md — it only nudges. Always exits 0.
set -euo pipefail

MIN_LINES="${HANDOVER_MIN_TRANSCRIPT_LINES:-50}"
MODE="${HANDOVER_OFFER_MODE:-context}"
OFFER_TEXT="This was a substantive session. Offer the user a one-line handover they can save via the /handover skill (which appends to RepoHandover.md); do not write anything unless they say yes."

input="$(cat)"
jqr() { printf '%s' "$input" | jq -r "$1" 2>/dev/null; }

# Gate 1: loop guard
[ "$(jqr '.stop_hook_active // false')" = "true" ] && exit 0

session_id="$(jqr '.session_id // ""')"
cwd="$(jqr '.cwd // ""')"
transcript="$(jqr '.transcript_path // ""')"
[ -n "$session_id" ] || exit 0

# Gate 2: once per session
sentinel="${TMPDIR:-/tmp}/claude-handover-${session_id}"
[ -e "$sentinel" ] && exit 0

# Gate 3: substantiveness — long transcript OR a dirty working tree.
tlines=0
[ -f "$transcript" ] && tlines="$(wc -l < "$transcript" | tr -d '[:space:]')"
dirty=""
if [ -n "$cwd" ] && git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  dirty="$(git -C "$cwd" status --porcelain 2>/dev/null | head -1)"
fi
substantive=false
[ "${tlines:-0}" -ge "$MIN_LINES" ] && substantive=true
[ -n "$dirty" ] && substantive=true
[ "$substantive" = true ] || exit 0

# All gates passed: mark sentinel, emit the soft offer.
: > "$sentinel" 2>/dev/null || true
if [ "$MODE" = "systemMessage" ]; then
  jq -n --arg m "$OFFER_TEXT" '{systemMessage:$m}'
else
  jq -n --arg m "$OFFER_TEXT" \
    '{hookSpecificOutput:{hookEventName:"Stop", additionalContext:$m}}'
fi
exit 0
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `chmod +x tools/handover/handover-offer.sh && bash tools/test_handover_hook.sh`
Expected: PASS — `PASSED=7 FAILED=0`, exit 0.

- [ ] **Step 5: Commit**

```bash
git add tools/handover/handover-offer.sh tools/test_handover_hook.sh
git commit -m "feat(handover): gated Stop-hook offer + tests"
```

---

## Task 3: Installer (`install.sh`)

Idempotently deploys the skill + hook to `~/.claude/` and merges the `Stop` hook into `~/.claude/settings.json` without clobbering existing keys. Hermetically testable via a fake `HOME`.

**Files:**
- Create: `tools/handover/install.sh`
- Test: `tools/test_handover_install.sh`

**Interfaces:**
- Consumes: `handover-offer.sh` and `SKILL.md` from `tools/handover/` (SKILL.md may be a stub until Task 4).
- Produces: `install.sh` honoring `CLAUDE_HOME` (default `$HOME/.claude`). Creates `skills/handover/SKILL.md`, `hooks/handover-offer.sh` (executable), and merges one `Stop` hook entry pointing at the absolute installed hook path. Running twice yields exactly one matching Stop entry (idempotent).

- [ ] **Step 1: Write the failing test**

Create `tools/test_handover_install.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

SRC="$(cd "$(dirname "$0")/handover" && pwd)"
INSTALL="$SRC/install.sh"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export HOME="$WORK/home"; mkdir -p "$HOME"
PASSED=0; FAILED=0
pass() { echo "✓ $1"; PASSED=$((PASSED+1)); }
fail() { echo "✗ $1"; FAILED=$((FAILED+1)); }

# Ensure SKILL.md exists (stub tolerated for this task)
[ -f "$SRC/SKILL.md" ] || echo "# handover (stub)" > "$SRC/SKILL.md"

bash "$INSTALL"

CH="$HOME/.claude"
[ -f "$CH/skills/handover/SKILL.md" ] && pass "SKILL.md installed" || fail "SKILL.md missing"
[ -x "$CH/hooks/handover-offer.sh" ] && pass "hook installed + executable" || fail "hook missing/not executable"

if jq -e . "$CH/settings.json" >/dev/null 2>&1; then pass "settings.json is valid JSON"; else fail "settings.json invalid"; fi

cmd="$(jq -r '.hooks.Stop[].hooks[].command' "$CH/settings.json" 2>/dev/null | grep -c 'handover-offer.sh' || true)"
[ "$cmd" = "1" ] && pass "exactly one Stop hook entry" || fail "expected 1 Stop entry, got $cmd"

# Idempotency: run again, still exactly one
bash "$INSTALL"
cmd="$(jq -r '.hooks.Stop[].hooks[].command' "$CH/settings.json" 2>/dev/null | grep -c 'handover-offer.sh' || true)"
[ "$cmd" = "1" ] && pass "idempotent: still one Stop entry" || fail "duplicate after re-install, got $cmd"

# Preserve unrelated pre-existing settings keys
echo '{"model":"opusish","hooks":{"Stop":[]}}' > "$CH/settings.json"
bash "$INSTALL"
[ "$(jq -r '.model' "$CH/settings.json")" = "opusish" ] && pass "preserves unrelated keys" || fail "clobbered unrelated keys"

echo "----"; echo "PASSED=$PASSED FAILED=$FAILED"
[ "$FAILED" = 0 ]
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `bash tools/test_handover_install.sh`
Expected: FAIL — `tools/handover/install.sh` does not exist yet.

- [ ] **Step 3: Write the installer**

Create `tools/handover/install.sh`:

```bash
#!/usr/bin/env bash
# Install the handover skill + Stop hook into the global Claude config.
# Honors CLAUDE_HOME (default: $HOME/.claude). Idempotent. Never commits.
set -euo pipefail

SRC="$(cd "$(dirname "$0")" && pwd)"
CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}"
SKILL_DIR="$CLAUDE_HOME/skills/handover"
HOOK_DIR="$CLAUDE_HOME/hooks"
SETTINGS="$CLAUDE_HOME/settings.json"
HOOK_DST="$HOOK_DIR/handover-offer.sh"

mkdir -p "$SKILL_DIR" "$HOOK_DIR"

install -m 0644 "$SRC/SKILL.md" "$SKILL_DIR/SKILL.md"
install -m 0755 "$SRC/handover-offer.sh" "$HOOK_DST"

[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
if ! jq -e . "$SETTINGS" >/dev/null 2>&1; then
  echo "install: $SETTINGS is not valid JSON; refusing to modify" >&2
  exit 1
fi

tmp="$(mktemp)"
jq --arg cmd "$HOOK_DST" '
  .hooks //= {} |
  .hooks.Stop //= [] |
  # Drop any prior entry that references our hook, then add a fresh one (idempotent).
  .hooks.Stop |= map(select((.hooks // [] | map(.command) | index($cmd)) | not)) |
  .hooks.Stop += [ { "matcher": "", "hooks": [ { "type": "command", "command": $cmd, "timeout": 10 } ] } ]
' "$SETTINGS" > "$tmp" && mv "$tmp" "$SETTINGS"

echo "handover installed:"
echo "  skill:    $SKILL_DIR/SKILL.md"
echo "  hook:     $HOOK_DST"
echo "  settings: $SETTINGS (Stop hook merged)"
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `chmod +x tools/handover/install.sh && bash tools/test_handover_install.sh`
Expected: PASS — `PASSED=7 FAILED=0`, exit 0. (No real `~/.claude` touched; test uses a fake `HOME`.)

- [ ] **Step 5: Commit**

```bash
git add tools/handover/install.sh tools/test_handover_install.sh
git commit -m "feat(handover): idempotent global installer + tests"
```

---

## Task 4: The `/handover` skill (`SKILL.md`)

Author the skill that drafts and writes entries. It is prose instruction to the agent (not unit-testable), so its acceptance is a **manual dry-run** whose *output* is validated by Task 1's `handover-lint.sh`. This ties the skill to the format contract.

**Files:**
- Create: `tools/handover/SKILL.md` (overwrites any Task 3 stub)

**Interfaces:**
- Consumes: `handover-lint.sh` (as the post-write self-check), git, the conversation transcript.
- Produces: a `SKILL.md` with YAML frontmatter (`name: handover`, a `description` that triggers on "handover", "hand off", "write handover", "capture state") and a procedure that: gathers git facts, drafts within the length budget, shows the draft for default-accept-on-Enter, prepends to `RepoHandover.md`, runs the supersede check, adds the `CLAUDE.md` pointer on first use, and never commits.

- [ ] **Step 1: Write the skill**

Create `tools/handover/SKILL.md`:

```markdown
---
name: handover
description: Use when wrapping up or capturing the current state of a coding session — writes a dated, structured entry to the repo's RepoHandover.md so a future agent or you can resume without grepping archived sessions. Triggers on "handover", "hand off", "write handover", "capture state", "checkpoint this session".
---

# Handover

Append one dated, structured entry to `RepoHandover.md` at the repo root (newest-first),
capturing the session's state so it can be resumed later. Serves both a future agent
(actionable state) and the human (skimmable narrative). You draft it; the user approves;
you write it. **Never commit** — the user commits.

## Procedure

### 1. Gather ground truth from git (never infer facts from th
