Startup Dashboard
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
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
/startupcommand: 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.csvclaims, live Claude Code sessions, sibling branches, recent handoffs (peer + self viahandoff.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-historymodule'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:
- macOS Keychain → direct Anthropic API (
~$0.015/run). Requires a one-time Keychain entry (see below). Never exportsANTHROPIC_API_KEY, so newclaudesessions keep their subscription / Max auth. claude -psubprocess (~$0.16/run). No setup. Loads the full Claude Code CLI harness as a system prompt, hence the higher cost.- Deterministic dashboard. Zero model tokens. Used when no
claudebinary 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.
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
}
]
}
]
}
}