Startup Dashboard

workflow no always-loaded rules -- loads on demand updated 2026-08-04

Plain-text /startup dashboard for Claude Code sessions. Surfaces git state, live sessions, tracking.csv claims, and recent activity (via session-history /recall). No agent-log writes; session transcripts are captured by Claude Code natively.

Tags

  • startup
  • dashboard
  • session

README

startup-dashboard

/startup produces a short, nicely-formatted markdown summary of the current repo state — what's happened in the last 48 hours, what's open, and what to work on next. Deterministic data gather + model-powered summarization.

What This Module Does

  • /startup command: runs the gather pipeline, feeds the output to a headless Sonnet model, and emits a high-signal summary with sections for Where we are / Recent activity / Open PRs / Top open issues / Live sessions / Next up.
  • Gather pipeline: collects git state, open PRs, 48h merges, priority issues, tracking.csv claims, live Claude Code sessions, sibling branches, recent handoffs (peer + self via handoff.py summary --include-self), orphan processes, release info, and recent session activity in parallel.
  • Recent Activity: unified 7-day view of session transcripts across every clone of the current repo, powered by the session-history module's /recall.

Files

File Type Description
commands/startup.md command /startup invokes the summary script
lib/startup-gather.sh lib Parallel data gather, emits === SECTION === blocks
lib/startup-summary.sh lib Runs the gather → model → markdown summary pipeline with a fallback chain
lib/startup-summary-prompt.md lib Fixed summary instructions the model receives
lib/startup-dashboard.sh lib Deterministic plain-text dashboard (fallback / --raw mode)
hooks/auto-startup.py hook SessionStart hook that runs /startup automatically on fresh sessions
settings.partial.json config Hook wiring configuration to merge into settings.json

Dependencies

  • session-history — supplies ~/.claude/scripts/recall.py, which powers the Recent Activity block.

Manual Installation

mkdir -p ~/.claude/commands ~/.claude/lib ~/.claude/hooks
cp commands/startup.md ~/.claude/commands/startup.md
cp lib/startup-gather.sh ~/.claude/lib/startup-gather.sh
cp lib/startup-summary.sh ~/.claude/lib/startup-summary.sh
cp lib/startup-dashboard.sh ~/.claude/lib/startup-dashboard.sh
cp lib/startup-summary-prompt.md ~/.claude/lib/startup-summary-prompt.md
cp hooks/auto-startup.py ~/.claude/hooks/auto-startup.py
chmod +x ~/.claude/lib/startup-gather.sh ~/.claude/lib/startup-summary.sh ~/.claude/lib/startup-dashboard.sh

Then merge settings.partial.json into ~/.claude/settings.json — it wires the SessionStart hook that runs /startup automatically. It is a fragment, not a whole file; copying it over settings.json would drop everything else.

Running

/startup           # Intelligent markdown summary
/startup --raw     # Deterministic plain-text dashboard (bypasses model)

Or directly:

bash ~/.claude/lib/startup-summary.sh
bash ~/.claude/lib/startup-dashboard.sh   # raw dashboard only
bash ~/.claude/lib/startup-gather.sh      # raw structured sections for debugging

How summarization works — fallback chain

startup-summary.sh tries three paths in order, stopping at the first that returns non-empty output:

  1. macOS Keychain → direct Anthropic API (~$0.015/run). Requires a one-time Keychain entry (see below). Never exports ANTHROPIC_API_KEY, so new claude sessions keep their subscription / Max auth.
  2. claude -p subprocess (~$0.16/run). No setup. Loads the full Claude Code CLI harness as a system prompt, hence the higher cost.
  3. Deterministic dashboard. Zero model tokens. Used when no claude binary is installed or every model path fails.

Enabling the cheap path (macOS only)

security add-generic-password -s ccgm-anthropic-api-key -a "$USER" -w sk-ant-...

macOS will prompt "Always Allow" the first time security reads the entry; after that it is silent. To remove it:

security delete-generic-password -s ccgm-anthropic-api-key

Tuning

All override-able via env vars at the top of startup-summary.sh:

Variable Default
CCGM_SUMMARY_MODEL (for claude -p) sonnet
CCGM_SUMMARY_MODEL_API (for direct API) claude-sonnet-4-6
CCGM_KEYCHAIN_SERVICE ccgm-anthropic-api-key

Customizing

  • Tune the summary style by editing lib/startup-summary-prompt.md.
  • Add new sections to the gather by editing lib/startup-gather.sh; the prompt will see them automatically.

Will install

Path Action Target Type
commands/startup.md commands/startup.md command
lib/startup-gather.sh lib/startup-gather.sh lib
lib/startup-dashboard.sh lib/startup-dashboard.sh lib
lib/startup-summary.sh lib/startup-summary.sh lib
lib/startup-summary-prompt.md lib/startup-summary-prompt.md lib
hooks/auto-startup.py hooks/auto-startup.py hook
settings.partial.json merge settings.json config

Dependencies

Required by

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/startup-dashboard.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install startup-dashboard@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

command (1)

commands/startup.md

---
description: Session startup - repo-aware intelligent summary
allowed-tools: Bash
---

# /startup - Session Startup

Run the summary script and display its output verbatim:

```bash
bash ~/.claude/lib/startup-summary.sh $ARGUMENTS
```

The script runs the gather pipeline, feeds the output to a headless Sonnet
model via `claude -p`, and emits a short markdown summary with sections for
Where we are / Recent activity / Open PRs / Top open issues / Live sessions /
Next up. Display the output as-is, then **stop and wait** for the user's next
instruction. Do NOT add commentary, do NOT continue into other work.

## Flags

- `/startup --raw` — skip the model pipeline; emit the deterministic plain-text
  dashboard produced by `startup-dashboard.sh`. Useful for debugging or when
  offline.

## How it works

1. `startup-gather.sh` collects structured data (git state, merges, PRs,
   tracking, sessions, priority issues, etc.) in parallel — deterministic,
   zero model tokens.
2. `startup-summary.sh` pipes the gather output plus a fixed prompt into
   `claude --model sonnet --no-session-persistence -p`. Summarization is a
   judgment task, so a model call is warranted.
3. If `claude` is missing, returns empty, or any step fails, the script falls
   back to `startup-dashboard.sh` automatically.

To debug the raw data the summary is working from:

```bash
bash ~/.claude/lib/startup-gather.sh
```
hook (1)

hooks/auto-startup.py

#!/usr/bin/env python3
"""
SessionStart hook: instruct Claude to run /startup on fresh sessions and
surface recent peer handoffs for the current repo.

Fires on SessionStart events whose `source == "startup"` (matcher scopes
this to `startup|resume` but the script stays silent on `resume` so that
resumed sessions pick up mid-task without being overridden by a dashboard
render).

Gated by CCGM_AUTO_STARTUP in ~/.claude/.ccgm.env. Disable by setting to
false (or unsetting).

Side effects on startup:
- Prints run-/startup reminder (always).
- Prints peer handoffs block (if any recent handoffs from OTHER clones).
- Prunes handoffs older than 30 days for the current repo (opportunistic).
"""
from __future__ import annotations

import json
import os
import sys
from pathlib import Path

ENV_FILE = Path.home() / ".claude" / ".ccgm.env"
HANDOFF_LIB = Path.home() / ".claude" / "lib" / "handoff.py"


def is_enabled() -> bool:
    if not ENV_FILE.exists():
        return False
    try:
        for line in ENV_FILE.read_text().splitlines():
            line = line.strip()
            if line.startswith("CCGM_AUTO_STARTUP="):
                return line.split("=", 1)[1].strip().lower() in ("true", "1", "yes")
    except OSError:
        pass
    return False


def _try_import_handoff():
    """Dynamically import the handoff lib without requiring sys.path setup."""
    if not HANDOFF_LIB.is_file():
        return None
    try:
        import importlib.util
        spec = importlib.util.spec_from_file_location("handoff", str(HANDOFF_LIB))
        if spec is None or spec.loader is None:
            return None
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        return mod
    except Exception:
        return None


def emit_handoff_block(cwd: str) -> None:
    """If peer handoffs exist for the current repo, print the summary block."""
    handoff = _try_import_handoff()
    if handoff is None:
        return
    try:
        repo = handoff.detect_repo(cwd=cwd)
        if not repo:
            return
        agent = handoff.detect_agent(cwd=cwd)
        summary = handoff.summarize_for_startup(repo, agent)
        if summary:
            print(summary)
        # Opportunistic cleanup: prune handoffs >30d for this repo
        try:
            handoff.prune_old_handoffs(repo=repo, days=30)
        except Exception:
            pass
    except Exception:
        # Never let handoff logic block the startup hook
        pass


def main() -> None:
    try:
        payload = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        payload = {}

    # Only fire on fresh sessions. Resume, clear, and compact are silent.
    if payload.get("source", "") != "startup":
        return

    if not is_enabled():
        return

    print("<auto-startup>")
    print("Run the /startup command now to initialize this session.")
    print("</auto-startup>")

    cwd = payload.get("cwd") or os.getcwd()
    emit_handoff_block(cwd)


if __name__ == "__main__":
    main()
lib (4)

lib/startup-gather.sh

#!/usr/bin/env bash
# startup-gather.sh - Parallel data gathering for /startup command
# Runs all independent checks concurrently and outputs structured sections.
# Called by the /startup agent; not meant to be run manually.

PROJECT_DIR="$PWD"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT

# --- Identity (synchronous - everything depends on these) ---

WC_MATCH=$(basename "$PWD" | grep -oE 'w[0-9]+-c[0-9]+$' 2>/dev/null || true)
if [ -n "$WC_MATCH" ]; then
  AGENT_ID="agent-${WC_MATCH}"
elif [ -f .env.clone ] && grep -q 'AGENT_ID=' .env.clone 2>/dev/null; then
  AGENT_ID=$(grep 'AGENT_ID=' .env.clone | cut -d= -f2)
else
  AGENT_NUM=$(basename "$PWD" | grep -oE '[0-9]+$' 2>/dev/null || echo "0")
  AGENT_ID="agent-${AGENT_NUM}"
fi

# Workspace-root detection: cwd itself isn't a git repo, but child clones exist.
IS_WORKSPACE_ROOT=false
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  for child in "$PWD"/*-c[0-9]*/; do
    if [ -d "$child" ] && git -C "$child" rev-parse --git-dir >/dev/null 2>&1; then
      IS_WORKSPACE_ROOT=true
      break
    fi
  done
fi

# Repo detection: direct if in a git repo, otherwise infer from a child clone.
REPO_NAME=$(git remote get-url origin 2>/dev/null | xargs basename 2>/dev/null | sed 's/\.git$//' || echo "")
if [ -z "$REPO_NAME" ] && [ "$IS_WORKSPACE_ROOT" = true ]; then
  for child in "$PWD"/*-c[0-9]*/; do
    [ -d "$child" ] || continue
    cand=$(git -C "$child" remote get-url origin 2>/dev/null | xargs basename 2>/dev/null | sed 's/\.git$//')
    if [ -n "$cand" ]; then
      REPO_NAME="$cand"
      break
    fi
  done
fi
[ -z "$REPO_NAME" ] && REPO_NAME="unknown"

TODAY=$(date +%Y%m%d)
NOW_TIME=$(date +%H:%M)

# Where to run gh (needs a git-repo cwd). In workspace mode, any child clone works.
GH_CWD="$PROJECT_DIR"
if [ "$IS_WORKSPACE_ROOT" = true ]; then
  for child in "$PWD"/*-c[0-9]*/; do
    if [ -d "$child" ] && git -C "$child" rev-parse --git-dir >/dev/null 2>&1; then
      GH_CWD="${child%/}"
      break
    fi
  done
fi

# --- Parallel jobs (all independent) ---

# 1. Git status + sync (clone mode only; workspace mode emits per-clone summary instead)
(
  cd "$PROJECT_DIR"
  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "NOT_A_GIT_REPO"
    exit 0
  fi
  BRANCH=$(git branch --show-current 2>/dev/null || echo "detached")
  echo "branch:$BRANCH"
  git fetch origin 2>/dev/null
  if git pull origin "$BRANCH" --ff-only 2>/dev/null; then
    echo "sync:ok"
  else
    echo "sync:diverged"
  fi
  AB=$(git rev-list --left-right --count origin/main...HEAD 2>/dev/null || echo "? ?")
  echo "ahead_behind:$AB"
  echo "---STATUS---"
  git status --short 2>/dev/null
  echo "---COMMITS---"
  git log --oneline -5 2>/dev/null
) > "$TMPDIR/git" 2>/dev/null &

# 1b. Per-clone summary (workspace mode only)
(
  if [ "$IS_WORKSPACE_ROOT" != true ]; then
    exit 0
  fi
  # Refresh origin/main in parallel so ahead/behind counts aren't stale.
  # Read-only: no pull, no rebase. See issue #380.
  for child in "$PROJECT_DIR"/*-c[0-9]*/; do
    [ -d "$child" ] || continue
    git -C "$child" rev-parse --git-dir >/dev/null 2>&1 || continue
    git -C "$child" fetch origin --quiet 2>/dev/null &
  done
  wait
  for child in "$PROJECT_DIR"/*-c[0-9]*/; do
    [ -d "$child" ] || continue
    git -C "$child" rev-parse --git-dir >/dev/null 2>&1 || continue
    name=$(basename "$child")
    # Drop the workspace prefix for a compact label (e.g. ccgm-w0-c1 -> c1)
    short=$(echo "$name" | grep -oE 'c[0-9]+$' || echo "$name")
    branch=$(git -C "$child" branch --show-current 2>/dev/null || echo "detached")
    dirty_count=$(git -C "$child" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
    if [ "$dirty_count" = "0" ]; then
      status="clean"
    else
      status="dirty(${dirty_count})"
    fi
    ab=$(git -C "$child" rev-list --left-right --count origin/main...HEAD 2>/dev/null || echo "")
    behind=$(echo "$ab" | awk '{print $1}')
    ahead=$(echo "$ab" | awk '{print $2}')
    if [ -z "$ab" ] || { [ "$behind" = "0" ] && [ "$ahead" = "0" ]; }; then
      sync="up to date"
    else
      sync="ahead ${ahead:-0}, behind ${behind:-0}"
    fi
    printf '%s\t%s\t%s\t%s\n' "$short" "$branch" "$status" "$sync"
  done
) > "$TMPDIR/clones" 2>/dev/null &

# 2. Open PRs
(
  cd "$GH_CWD" 2>/dev/null || exit 0
  gh pr list --state open --limit 10 2>/dev/null || echo "none"
) > "$TMPDIR/prs" 2>/dev/null &

# 3. Tracking dashboard (active claims only)
(
  if [ -n "$REPO_NAME" ] && [ "$REPO_NAME" != "unknown" ]; then
    python3 ~/.claude/lib/agent_tracking.py list --repo "$REPO_NAME" 2>/dev/null || echo "unavailable"
  else
    echo "(unknown repo - tracking unavailable)"
  fi
) > "$TMPDIR/tracking" 2>/dev/null &

# 4. Live sessions
(
  python3 ~/.claude/lib/agent_sessions.py --text --exclude-cwd "$PROJECT_DIR" 2>/dev/null || true
) > "$TMPDIR/sessions" 2>/dev/null &

# 5. Sibling branches (clone mode only; workspace mode uses the CLONES section)
(
  if [ "$IS_WORKSPACE_ROOT" = true ]; then
    exit 0
  fi
  cd "$PROJECT_DIR"
  WC=$(basename "$PWD" | grep -oE 'w[0-9]+-c[0-9]+$' 2>/dev/null || true)
  if [ -n "$WC" ]; then
    WS_DIR=$(dirname "$PWD")
    for dir in "$WS_DIR"/*-c[0-9]*/; do
      [ -d "$dir" ] && [ "$dir" != "$PWD/" ] && \
        echo "$(basename "$dir"): $(git -C "$dir" branch --show-current 2>/dev/null)"
    done
  else
    REPOS_DIR=$(dirname "$PWD")
    REPO_BASE=$(basename "$PWD" | sed 's/-[0-9]*$//')
    for dir in "$REPOS_DIR/${REPO_BASE}"-[0-9]*; do
      [ -d "$dir" ] && [ "$dir" != "$PWD" ] && \
        echo "$(basename "$dir"): $(git -C "$dir" branch --show-current 2>/dev/null)"
    done
  fi
) > "$TMPDIR/siblings" 2>/dev/null &

# 6. Orphan process check
(
  python3 ~/.claude/hooks/orphan-process-check.py 2>/dev/null || true
) > "$TMPDIR/orphans" 2>/dev/null &

# 6b. Recent handoffs (peer + self, last 3d)
# Surfaces handoffs the previous session wrote via /sds or /handoff so the
# next /startup picks them up. --include-self covers the /sds → /startup
# self-continuity case; peer handoffs from sibling clones come along too.
(
  if [ -z "$REPO_NAME" ] || [ "$REPO_NAME" = "unknown" ]; then
    exit 0
  fi
  python3 ~/.claude/lib/handoff.py summary \
    --repo "$REPO_NAME" --include-self --max 3 --days 3 2>/dev/null || true
) > "$TMPDIR/handoffs" 2>/dev/null &

# 7. Release check
(
  CURRENT=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
  LATEST=$(npm view @anthropic-ai/claude-code version 2>/dev/null || echo "")
  echo "current:${CURRENT:-unknown}"
  echo "latest:${LATEST:-unknown}"
  if [ -n "$CURRENT" ] && [ -n "$LATEST" ] && [ "$CURRENT" != "$LATEST" ]; then
    echo "UPDATE_AVAILABLE"
  fi
) > "$TMPDIR/release" 2>/dev/null &

# 8. Recent activity (7-day session history)
(
  if [ -n "$REPO_NAME" ] && [ "$REPO_NAME" != "unknown" ] && [ -x "$(command -v python3)" ]; then
    python3 "$HOME/.claude/scripts/recall.py" --summary --limit 3 --days 7 2>/dev/null || true
  fi
) > "$TMPDIR/recent" 2>/dev/null &

# 9. Recent merges (last 48h on main)
(
  cd "$GH_CWD" 2>/dev/null || exit 0
  cutoff=$(python3 -c "import datetime; print((datetime.datetime.utcnow()-datetime.timedelta(hours=48)).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null)
  [ -z "$cutoff" ] && exit 0
  gh pr list --state merged --limit 30 --json number,title,mergedAt,author \
    --jq ".[] | select(.mergedAt > \"$cutoff\") | \"#\\(.number)\\t\\(.mergedAt)\\t\\(.author.login)\\t\\(.title)\"" \
    2>/dev/null || true
) > "$TMPDIR/recent_merges" 2>/dev/null &

# 10a. Priority issues (top 5 open, ordered by priority labels then recency)
(
  cd "$GH_CWD" 2>/dev/null || exit 0
  # Issues with priority labels first, then most recently updated.
  gh issue list --state open --limit 40 --json number,title,labels,updatedAt \
    --jq '[.[] | {n: .number, t: .title, labels: [.labels[].name], u: .updatedAt}]
          | map(. + {priority: (
              if any(.labels[]; . == "p0" or . == "P0" or . == "critical") then 0
              elif any(.labels[]; . == "p1" or . == "P1" or . == "high-priority" or . == "priority") then 1
              elif any(.labels[]; . == "bug") then 2
              elif any(.labels[]; . == "p2" or . == "P2") then 3
              else 4 end)})
          | sort_by(.priority, (.u | split("T")[0] | split("-") | map(tonumber) | (0-.[0]*10000 - .[1]*100 - .[2])))
          | .[0:5]
          | .[] | "#\(.n)\t\((.labels | join(",")) // "")\t\(.t)"' \
    2>/dev/null || true
) > "$TMPDIR/priority_issues" 2>/dev/null &

# 10b. Candidate issues to pick up (open, no linked open PR, not already claimed)
(
  cd "$GH_CWD" 2>/dev/null || exit 0
  # Build set of issue numbers referenced by open PRs (Closes #N / Fixes #N / Resolves #N).
  open_pr_bodies=$(gh pr list --state open --limit 30 --json body,headRefName --jq '.[] | "\(.body) \(.headRefName)"' 2>/dev/null)
  claimed_issues=$(echo "$open_pr_bodies" | grep -oiE '(closes|fixes|resolves) +#[0-9]+' | grep -oE '[0-9]+' | sort -u)
  # Also treat branch names like "123-foo" as a claim on #123.
  branch_claims=$(echo "$open_pr_bodies" | grep -oE '\b[0-9]+-[a-z0-9-]+' | grep -oE '^[0-9]+' | sort -u)
  claimed=$(printf '%s\n%s\n' "$claimed_issues" "$branch_claims" | sort -u | grep -v '^$')
  gh issue list --state open --limit 20 --json number,title,labels \
    --jq '.[] | "\(.number)\t\(.title)"' 2>/dev/null | while IFS=$'\t' read -r n title; do
      if ! echo "$claimed" | grep -qx "$n"; then
        printf '#%s\t%s\n' "$n" "$title"
      fi
    done | head -5
) > "$TMPDIR/candidate_issues" 2>/dev/null &

# Wait for all background jobs
wait

# --- Structured output ---

cat <<GATHER_EOF
=== IDENTITY ===
agent_id:${AGENT_ID}
repo:${REPO_NAME}
date:${TODAY}
time:${NOW_TIME}
project_dir:${PROJECT_DIR}
is_workspace_root:${IS_WORKSPACE_ROOT}

=== GIT ===
$(cat "$TMPDIR/git")

=== CLONES ===
$(cat "$TMPDIR/clones")

=== PRS ===
$(cat "$TMPDIR/prs")

=== TRACKING ===
$(cat "$TMPDIR/tracking")

=== SESSIONS ===
$(cat "$TMPDIR/sessions")

=== SIBLINGS ===
$(cat "$TMPDIR/siblings")

=== HANDOFFS ===
$(cat "$TMPDIR/handoffs")

=== ORPHANS ===
$(cat "$TMPDIR/orphans")

=== RELEASE ===
$(cat "$TMPDIR/release")

=== RECENT_ACTIVITY ===
$(cat "$TMPDIR/recent")

=== RECENT_MERGES ===
$(cat "$TMPDIR/recent_merges")

=== CANDIDATE_ISSUES ===
$(cat "$TMPDIR/candidate_issues")

=== PRIORITY_ISSUES ===
$(cat "$TMPDIR/priority_issues")
GATHER_EOF

lib/startup-dashboard.sh

#!/usr/bin/env bash
# startup-dashboard.sh - Run gather and emit formatted dashboard.
# Replaces the model-delegated formatting step of /startup (#335).
# Called directly by /startup; no Agent tool dispatch, no model tokens for formatting.

set -u

GATHER_SCRIPT="${CCGM_GATHER_SCRIPT:-$HOME/.claude/lib/startup-gather.sh}"

if [ ! -f "$GATHER_SCRIPT" ]; then
  echo "startup-dashboard: gather script not found at $GATHER_SCRIPT" >&2
  exit 1
fi

GATHER=$(bash "$GATHER_SCRIPT" 2>/dev/null)
if [ -z "$GATHER" ]; then
  echo "startup-dashboard: gather produced no output" >&2
  exit 1
fi

# ---- Section extraction ----
section() {
  local name="$1"
  printf '%s\n' "$GATHER" | awk -v marker="=== $name ===" '
    $0 == marker { inside = 1; next }
    /^=== / && inside { exit }
    inside { print }
  '
}

kv() {
  local name="$1" key="$2"
  section "$name" | awk -F: -v k="$key" '$1 == k { sub(/^[^:]*:/, ""); print; exit }'
}

trim() {
  local s="$1"
  s="${s#"${s%%[![:space:]]*}"}"
  s="${s%"${s##*[![:space:]]}"}"
  printf '%s' "$s"
}

indent() {
  awk 'NF { print "  " $0 } !NF { print }'
}

# ---- Extract fields ----
AGENT_ID=$(kv IDENTITY agent_id)
REPO=$(kv IDENTITY repo)
DATE=$(kv IDENTITY date)
IS_WORKSPACE_ROOT=$(kv IDENTITY is_workspace_root)

GIT_SECTION=$(section GIT)
if printf '%s' "$GIT_SECTION" | grep -q '^NOT_A_GIT_REPO$'; then
  BRANCH="(not a git repo)"
  SYNC=""
  AHEAD_BEHIND=""
  GIT_STATUS_BODY=""
else
  BRANCH=$(printf '%s\n' "$GIT_SECTION" | awk -F: '$1 == "branch" { sub(/^[^:]*:/, ""); print; exit }')
  SYNC=$(printf '%s\n' "$GIT_SECTION" | awk -F: '$1 == "sync" { sub(/^[^:]*:/, ""); print; exit }')
  AHEAD_BEHIND=$(printf '%s\n' "$GIT_SECTION" | awk -F: '$1 == "ahead_behind" { sub(/^[^:]*:/, ""); print; exit }')
  GIT_STATUS_BODY=$(printf '%s\n' "$GIT_SECTION" | awk '
    /^---STATUS---$/ { inside = 1; next }
    /^---COMMITS---$/ { inside = 0 }
    inside { print }
  ')
fi

CLONES_BODY=$(section CLONES)
PRS_BODY=$(section PRS)
TRACKING_BODY=$(section TRACKING)
SESSIONS_BODY=$(section SESSIONS)
SIBLINGS_BODY=$(section SIBLINGS)
ORPHANS_BODY=$(section ORPHANS)
RECENT_BODY=$(section RECENT_ACTIVITY)
RECENT_MERGES_BODY=$(section RECENT_MERGES)
CANDIDATE_ISSUES_BODY=$(section CANDIDATE_ISSUES)

RELEASE_CURRENT=$(kv RELEASE current)
RELEASE_LATEST=$(kv RELEASE latest)
UPDATE_AVAILABLE=0
if printf '%s\n' "$GATHER" | grep -q '^UPDATE_AVAILABLE$'; then
  UPDATE_AVAILABLE=1
fi

# ---- Summaries ----

if [ "$BRANCH" = "(not a git repo)" ]; then
  STATUS_LABEL="n/a"
  SYNC_LABEL="n/a"
elif [ -n "$(trim "$GIT_STATUS_BODY")" ]; then
  STATUS_LABEL="dirty"
else
  STATUS_LABEL="clean"
fi

if [ "$BRANCH" != "(not a git repo)" ]; then
  AB_TRIMMED=$(trim "$AHEAD_BEHIND")
  case "$AB_TRIMMED" in
    "0	0"|"0 0"|"")
      SYNC_LABEL="up to date"
      ;;
    *)
      BEHIND=$(printf '%s' "$AB_TRIMMED" | awk '{print $1}')
      AHEAD=$(printf '%s' "$AB_TRIMMED" | awk '{print $2}')
      SYNC_LABEL="ahead ${AHEAD:-0}, behind ${BEHIND:-0}"
      ;;
  esac
fi

# Parse CLONES body to find dirty clones (workspace mode).
DIRTY_CLONES=""
if [ "$IS_WORKSPACE_ROOT" = "true" ] && [ -n "$(trim "$CLONES_BODY")" ]; then
  while IFS=$'\t' read -r clone branch status sync; do
    [ -z "$clone" ] && continue
    case "$status" in
      dirty*) DIRTY_CLONES="${DIRTY_CLONES:+$DIRTY_CLONES, }${clone}" ;;
    esac
  done <<EOF
$CLONES_BODY
EOF
fi

# PR count for header
PR_COUNT=0
if [ -n "$(trim "$PRS_BODY")" ] && [ "$(trim "$PRS_BODY")" != "none" ]; then
  PR_COUNT=$(printf '%s\n' "$PRS_BODY" | grep -cE '^[0-9]+' || true)
  [ -z "$PR_COUNT" ] && PR_COUNT=0
fi

# Recent merge count
MERGE_COUNT=0
if [ -n "$(trim "$RECENT_MERGES_BODY")" ]; then
  MERGE_COUNT=$(printf '%s\n' "$RECENT_MERGES_BODY" | grep -c . || true)
  [ -z "$MERGE_COUNT" ] && MERGE_COUNT=0
fi

# First candidate issue (if any)
FIRST_CANDIDATE=""
if [ -n "$(trim "$CANDIDATE_ISSUES_BODY")" ]; then
  FIRST_CANDIDATE=$(printf '%s\n' "$CANDIDATE_ISSUES_BODY" | head -1)
fi

# Recommendation priority:
# 1. Open PRs (review/merge)
# 2. Dirty working tree (in-clone) or dirty clones (workspace mode)
# 3. Unclaimed open issue
# 4. Fallback
NEXT="What would you like to work on?"
if [ "$PR_COUNT" -gt 0 ] 2>/dev/null; then
  NEXT="Review ${PR_COUNT} open PR(s)"
elif [ "$STATUS_LABEL" = "dirty" ]; then
  NEXT="Review uncommitted changes or continue previous work"
elif [ -n "$DIRTY_CLONES" ]; then
  NEXT="Uncommitted changes in: ${DIRTY_CLONES}"
elif [ -n "$FIRST_CANDIDATE" ]; then
  # Render as "Pick up <first candidate>"
  NEXT="Pick up $(printf '%s' "$FIRST_CANDIDATE" | awk -F'\t' '{printf "%s: %s", $1, $2}')"
fi

# ---- Emit dashboard ----

REPO_DISPLAY="$REPO"
[ -z "$REPO_DISPLAY" ] && REPO_DISPLAY="(no repo)"
if [ "$IS_WORKSPACE_ROOT" = "true" ] && [ "$REPO_DISPLAY" != "(no repo)" ] && [ "$REPO_DISPLAY" != "unknown" ]; then
  REPO_DISPLAY="${REPO_DISPLAY} (workspace)"
fi

if [ ${#DATE} -eq 8 ]; then
  DATE_PRETTY="${DATE:0:4}-${DATE:4:2}-${DATE:6:2}"
else
  DATE_PRETTY="$DATE"
fi

printf '%s  ·  %s  ·  %s\n' "$AGENT_ID" "$REPO_DISPLAY" "$DATE_PRETTY"

# Header line differs by mode. In workspace mode, the Clones section carries the
# per-clone detail, so the header just summarizes repo-level state.
if [ "$IS_WORKSPACE_ROOT" = "true" ]; then
  CLONE_COUNT=0
  if [ -n "$(trim "$CLONES_BODY")" ]; then
    CLONE_COUNT=$(printf '%s\n' "$CLONES_BODY" | grep -c . || true)
  fi
  printf 'Workspace  %s clone(s)\n' "${CLONE_COUNT:-0}"
else
  printf 'Branch    %-30s  Status  %-8s  Sync  %s\n' "$BRANCH" "$STATUS_LABEL" "$SYNC_LABEL"
fi

emit_section() {
  local label="$1" body="$2"
  local trimmed
  trimmed=$(trim "$body")
  [ -z "$trimmed" ] && return 0
  [ "$trimmed" = "none" ] && return 0
  [ "$trimmed" = "(unknown repo - tracking unavailable)" ] && return 0
  [ "$trimmed" = "(coordinator workspace - tracking shown by individual clones)" ] && return 0
  printf '\n%s\n' "$label"
  printf '%s\n' "$body" | indent
}

# Clones table (workspace mode only)
if [ "$IS_WORKSPACE_ROOT" = "true" ] && [ -n "$(trim "$CLONES_BODY")" ]; then
  printf '\nClones\n'
  printf '%s\n' "$CLONES_BODY" | awk -F'\t' '{printf "  %-4s  %-36s  %-12s  %s\n", $1, $2, $3, $4}'
fi

emit_section "Live Sessions" "$SESSIONS_BODY"

if [ "$PR_COUNT" -gt 0 ] 2>/dev/null; then
  printf '\nOpen PRs (%s)\n' "$PR_COUNT"
  printf '%s\n' "$PRS_BODY" | indent
fi

# Recent merges (last 48h)
if [ "$MERGE_COUNT" -gt 0 ] 2>/dev/null; then
  printf '\nMerged (last 48h, %s)\n' "$MERGE_COUNT"
  printf '%s\n' "$RECENT_MERGES_BODY" | awk -F'\t' '{
    # Shorten mergedAt YYYY-MM-DDThh:mm:ssZ -> MM-DD hh:mm
    t = $2
    date = substr(t, 6, 5)
    time = substr(t, 12, 5)
    printf "  %s  %s %s  %s  %s\n", $1, date, time, $3, $4
  }'
fi

emit_section "Tracking" "$TRACKING_BODY"
emit_section "Recent Activity" "$RECENT_BODY"
emit_section "Siblings" "$SIBLINGS_BODY"

ORPHANS_TRIMMED=$(trim "$ORPHANS_BODY")
if [ -n "$ORPHANS_TRIMMED" ]; then
  printf '\nOrphans\n'
  printf '%s\n' "$ORPHANS_BODY" | indent
fi

if [ "$UPDATE_AVAILABLE" = "1" ]; then
  printf '\nUpdate    v%s -> v%s   (npm i -g @anthropic-ai/claude-code@latest)\n' \
    "$(trim "$RELEASE_CURRENT")" "$(trim "$RELEASE_LATEST")"
fi

printf '\nNext  %s\n' "$NEXT"

lib/startup-summary.sh

#!/usr/bin/env bash
# startup-summary.sh - Intelligent summary for /startup.
#
# Fallback chain (each step runs only if the previous failed or was empty):
#   1. macOS Keychain → direct Anthropic API call (cheap, ~$0.015/run)
#   2. claude -p subprocess (loads CLI harness, ~$0.16/run, no setup needed)
#   3. Deterministic startup-dashboard.sh (no model tokens)
#
# To enable the cheap path, store your API key in Keychain once:
#   security add-generic-password -s ccgm-anthropic-api-key -a "$USER" -w <sk-ant-...>
# The script never exports ANTHROPIC_API_KEY to the parent shell, so new
# `claude` sessions continue to use Max / subscription auth.
#
# Usage: bash startup-summary.sh [--raw]
#   --raw : skip the model pipeline, emit the deterministic dashboard directly.

set -u

GATHER_SCRIPT="${CCGM_GATHER_SCRIPT:-$HOME/.claude/lib/startup-gather.sh}"
DASHBOARD_SCRIPT="${CCGM_DASHBOARD_SCRIPT:-$HOME/.claude/lib/startup-dashboard.sh}"
PROMPT_FILE="${CCGM_SUMMARY_PROMPT:-$HOME/.claude/lib/startup-summary-prompt.md}"
SUMMARY_MODEL="${CCGM_SUMMARY_MODEL:-sonnet}"
SUMMARY_MODEL_API="${CCGM_SUMMARY_MODEL_API:-claude-sonnet-4-6}"
KEYCHAIN_SERVICE="${CCGM_KEYCHAIN_SERVICE:-ccgm-anthropic-api-key}"

run_dashboard() {
  if [ -x "$(command -v bash)" ] && [ -f "$DASHBOARD_SCRIPT" ]; then
    bash "$DASHBOARD_SCRIPT"
  else
    echo "startup-summary: dashboard fallback unavailable" >&2
    return 1
  fi
}

# Try the cheap path: Keychain lookup → direct Anthropic API via curl.
# Returns the summary on stdout if successful; empty stdout on any failure.
# Never exports ANTHROPIC_API_KEY to the parent shell.
try_direct_api() {
  command -v security >/dev/null 2>&1 || return 1
  command -v curl >/dev/null 2>&1 || return 1
  command -v jq >/dev/null 2>&1 || return 1

  local api_key
  api_key=$(security find-generic-password -s "$KEYCHAIN_SERVICE" -w 2>/dev/null) || return 1
  [ -z "$api_key" ] && return 1

  local prompt_content
  prompt_content=$(cat "$PROMPT_FILE" "$1" 2>/dev/null)
  [ -z "$prompt_content" ] && return 1

  local payload
  payload=$(jq -nc \
    --arg m "$SUMMARY_MODEL_API" \
    --arg p "$prompt_content" \
    '{model: $m, max_tokens: 1500, messages: [{role: "user", content: $p}]}' 2>/dev/null) || return 1

  local response
  response=$(printf '%s' "$payload" | curl -sS --max-time 30 \
    https://api.anthropic.com/v1/messages \
    -H "x-api-key: $api_key" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d @- 2>/dev/null) || return 1

  [ -z "$response" ] && return 1

  local text
  text=$(printf '%s' "$response" | jq -r '.content[0].text // empty' 2>/dev/null)
  [ -z "$text" ] && return 1

  printf '%s' "$text"
}

# --raw skips the model pipeline entirely.
if [ "${1:-}" = "--raw" ]; then
  exec bash "$DASHBOARD_SCRIPT"
fi

# If any input is missing, fall back to the dashboard.
if ! command -v claude >/dev/null 2>&1; then
  run_dashboard
  exit $?
fi
if [ ! -f "$GATHER_SCRIPT" ] || [ ! -f "$PROMPT_FILE" ]; then
  run_dashboard
  exit $?
fi

TMPGATHER=$(mktemp -t ccgm-startup-gather.XXXXXX)
trap "rm -f $TMPGATHER" EXIT

bash "$GATHER_SCRIPT" > "$TMPGATHER" 2>/dev/null
if [ ! -s "$TMPGATHER" ]; then
  run_dashboard
  exit $?
fi

# Path 1: Keychain → direct Anthropic API (cheap).
SUMMARY=$(try_direct_api "$TMPGATHER")

# Path 2: claude -p subprocess (loads CLI harness, no setup required).
if [ -z "$SUMMARY" ]; then
  SUMMARY=$(cat "$PROMPT_FILE" "$TMPGATHER" 2>/dev/null \
    | claude --model "$SUMMARY_MODEL" --no-session-persistence -p 2>/dev/null)
fi

# Path 3: deterministic dashboard fallback.
if [ -z "$SUMMARY" ]; then
  echo "startup-summary: model pipeline returned empty; falling back to dashboard" >&2
  run_dashboard
  exit $?
fi

printf '%s\n' "$SUMMARY"

lib/startup-summary-prompt.md

You are summarizing a session startup dashboard for an experienced engineer.
Produce a short, high-signal plain-text summary. Work ONLY from the gather
output below — do not make tool calls, do not invent facts.

IMPORTANT: Output is rendered as Bash tool output in Claude Code, which does
NOT render markdown. Use plain text only — no `**bold**`, no `##` headers,
no backticks for emphasis. Section headers are plain lines followed by a
blank line.

## Output format

Use these exact section headers in order. Omit any section whose data is
absent, empty, or "none":

```
<agent_id> · <repo> · <branch-or-"workspace"> · <date>

Where we are
- 1-2 lines. Branch state, dirty/clean, sync with main. In workspace mode,
  name the clones and their branches compactly on one line.

Last handoff
- One bullet per recent handoff from the HANDOFFS section: "agent (age) — title".
  Mark self-authored entries with "(you)" preserved from the HANDOFFS data —
  these are notes the previous session in this clone wrote for "future-me"
  (e.g., from /sds), so prioritize surfacing them.
- If a handoff entry has a "Next steps" line (or legacy "What's next"),
  indent it as a sub-bullet beneath the entry so the next action is
  visible at a glance.
- OMIT this section entirely if HANDOFFS is empty or missing.

Recent activity (last 48h)
- 3-5 bullets summarizing the RECENT_MERGES section. Group related PRs by
  theme when possible (e.g., "Cleanup wave across X, Y, Z — #540, #539, #538").
  Prefer significance over literal listing.

Open PRs
- One bullet per PR: #N title. OMIT this section entirely if PRS is
  empty or "none".

Top open issues
- 3-5 bullets from PRIORITY_ISSUES. Prefix notable labels in brackets
  (e.g., "[bug]", "[p0]"). OMIT this section if PRIORITY_ISSUES is empty.

Live sessions
- Single line: count and one notable detail if any. OMIT if no sessions
  besides self.

Next up
- ONE concrete, grounded action. Priority order:
  1. self-handoff "Next steps" / legacy "What's next" (HANDOFFS marked
     "(you)" — the previous session in this clone left a concrete next
     step; honor it)
  2. open PR to review (name it: "Review PR #X")
  3. dirty working tree (clone mode)
  4. dirty clones (workspace mode — name them)
  5. top unclaimed issue (name it: "Pick up #X: title")
  6. generic: "Pick a task."
```

## Rules

- Be terse. Total output 15-25 lines.
- No markdown formatting anywhere. No `**`, no `##`, no `#`, no backticks.
- Section headers are bare text on their own line, followed by a blank line
  before bullets.
- No preamble, no sign-off, no "Summary:" prefix. Just the text.
- If RECENT_MERGES is empty, say: "- (no merges in last 48h)"
- Pretty-print the date: YYYYMMDD → YYYY-MM-DD.
- Header line format:
  `{agent_id} · {repo} · {branch_or_"workspace"} · {YYYY-MM-DD}`
  - If `is_workspace_root:true`, use `workspace` instead of a branch name.
  - If `repo:unknown`, use `(no repo)`.

## Gather output

config (1)

settings.partial.json

Merged into ~/.claude/settings.json -- a fragment, not a replacement.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "python3 $HOME/.claude/hooks/auto-startup.py",
            "timeout": 3000
          }
        ]
      }
    ]
  }
}