diff --git a/.claude/hooks/preflight.sh b/.claude/hooks/preflight.sh index cbeb198..9e59919 100644 --- a/.claude/hooks/preflight.sh +++ b/.claude/hooks/preflight.sh @@ -1,30 +1,30 @@ #!/usr/bin/env bash # ============================================================================= -# Desktop Command Center — CLI Agent Pre-flight Check +# DifferentWire Standard Preflight Hook # ============================================================================= # Runs on SessionStart. Blocks the session (exit 2) if required coordination -# services are unavailable. Auto-starts the Dolt SSH tunnel if it's down. +# services are unavailable. ALL checks are hard blocks. # # Required services: -# 1. Dolt SSH tunnel (localhost → Hetzner:3307) — for Beads task tracking -# 2. MCP Agent Mail (getunfocused.app/mcp/) — for multi-agent coordination -# -# Zombie port detection: -# If a previous session's SSH tunnel died without closing its socket, -# the port stays LISTENING with a dead PID. This hook detects that and -# falls back to alternate ports (3308, 3309). +# 1. Citadel API — task management (HARD BLOCK) +# 2. Agent Mail — multi-agent coordination (HARD BLOCK) # # Exit codes: -# 0 = all checks passed, session may proceed -# 2 = BLOCKING — required service unavailable, session must not start +# 0 = all checks passed +# 2 = BLOCKING — required service unavailable +# +# Projects copy this to .claude/hooks/preflight.sh and customize PROJECT_DIR. # ============================================================================= set -euo pipefail -HETZNER_HOST="unfocused@46.224.181.82" -DOLT_PORTS=(3307 3308 3309) # Cascade: try each until one works +# ─── Project-specific overrides (edit these) ────────────────────────────── +PROJECT_DIR="/c/Dev/Desk_Command_Center" +# ────────────────────────────────────────────────────────────────────────── + +CITADEL_HEALTH="https://getunfocused.app/citadel/health" AGENT_MAIL_HEALTH="https://getunfocused.app/health/liveness" -PID_FILE="$HOME/.dcc-tunnel.pid" +HETZNER_HOST="unfocused@46.224.181.82" PASS="✓" FAIL="✗" @@ -37,215 +37,106 @@ fail() { log "$FAIL $1"; errors=$((errors + 1)); } warn() { log "$WARN $1"; } log "" -log "═══ Desktop Command Center Pre-flight Check ═══" +log "═══ Preflight Check ═══" log "" -# ─── Helper: check if a PID is a live process ───────────────────────────── -pid_alive() { - local pid=$1 - # tasklist on Windows — returns 0 if process exists - if tasklist //FI "PID eq $pid" 2>/dev/null | grep -q "$pid"; then - return 0 - fi - return 1 -} - -# ─── Helper: check if port is listening AND the PID is alive ────────────── -port_healthy() { - local port=$1 - local pid - pid=$(netstat -ano 2>/dev/null | grep "127.0.0.1:${port}" | grep -i "listen" | awk '{print $NF}' | head -1) - - if [ -z "$pid" ]; then - return 1 # Port not listening at all - fi - - if pid_alive "$pid"; then - return 0 # Port listening with a live process — healthy - else - warn "Port $port held by zombie PID $pid (process dead, socket orphaned)" - return 1 - fi -} - -# ─── Helper: kill old tunnel from PID file ──────────────────────────────── -cleanup_old_tunnel() { - if [ -f "$PID_FILE" ]; then - local old_pid - old_pid=$(cat "$PID_FILE" 2>/dev/null || true) - if [ -n "$old_pid" ] && pid_alive "$old_pid"; then - log " Killing previous tunnel (PID $old_pid)..." - taskkill //F //PID "$old_pid" 2>/dev/null || kill "$old_pid" 2>/dev/null || true - sleep 1 - fi - rm -f "$PID_FILE" - fi -} - -# ─── 1. Dolt SSH Tunnel ───────────────────────────────────────────────────── -# Beads connects to Dolt via localhost. This requires an SSH tunnel -# forwarding to the Hetzner server where Dolt runs. - -ACTIVE_PORT="" - -# First, try to clean up any previous tunnel -cleanup_old_tunnel - -# Try each port in the cascade -for port in "${DOLT_PORTS[@]}"; do - if port_healthy "$port"; then - ACTIVE_PORT=$port - pass "Dolt SSH tunnel (port $port — existing healthy tunnel)" - break - fi - - # Port not healthy (either not listening or zombie). Try to start tunnel. - # -o ExitOnForwardFailure=yes = fail immediately if port is already bound - if ssh -o ExitOnForwardFailure=yes -fNL "${port}:127.0.0.1:3307" "$HETZNER_HOST" 2>/dev/null; then - sleep 2 - if port_healthy "$port"; then - # Save the PID for cleanup by next session - local_pid=$(netstat -ano 2>/dev/null | grep "127.0.0.1:${port}" | grep -i "listen" | awk '{print $NF}' | head -1) - echo "$local_pid" > "$PID_FILE" - ACTIVE_PORT=$port - pass "Dolt SSH tunnel (auto-started on port $port)" - break - fi - fi - - # This port didn't work (zombie or bind failure), try next - if [ "$port" != "${DOLT_PORTS[-1]}" ]; then - warn "Port $port unavailable, trying next..." - fi -done - -if [ -z "$ACTIVE_PORT" ]; then - fail "Dolt SSH tunnel — all ports (${DOLT_PORTS[*]}) failed" - log " Zombie ports may require system reboot to release." - log " Manual fix: ssh -fNL 3307:127.0.0.1:3307 ${HETZNER_HOST}" -fi - -# ─── 2. Beads Connectivity ────────────────────────────────────────────────── -# Verify Beads can actually talk to Dolt through the tunnel. - -if [ -n "$ACTIVE_PORT" ]; then - export PATH="/c/Dev/flutter/bin:$PATH" - export BD_DSN="root:@tcp(127.0.0.1:${ACTIVE_PORT})/Desk_Command_Center" - - if bd list --quiet 2>/dev/null | head -1 >/dev/null 2>&1; then - pass "Beads (bd list via port $ACTIVE_PORT)" - - # ─── 2b. Pull latest Beads data from Dolt remote ───────────── - # Prevents agents from starting with stale task state. - # Warning only — stale data is better than a blocked session. - # - # Auto-commit metadata first: `dolt push` writes tracking data to - # the metadata table outside SQL transactions, leaving dirty state - # that blocks all future pulls. Commit it silently before pulling. - ssh -o ConnectTimeout=5 "$HETZNER_HOST" \ - "docker exec dolt-beads dolt --data-dir=/var/lib/dolt sql -q \ - \"USE Desk_Command_Center; CALL dolt_add('metadata'); CALL dolt_commit('-m', 'chore: auto-commit metadata before pull', '--allow-empty');\"" \ - 2>/dev/null || true - - if bd dolt pull 2>/dev/null; then - pass "Beads data synced (bd dolt pull)" - else - warn "bd dolt pull failed — session may have stale Beads data" - log " This is a warning, not a blocker. Continuing." - fi - else - # Tunnel is up but Beads can't connect — maybe Dolt server isn't running - fail "Beads cannot reach Dolt (tunnel up but Dolt may not be running)" - log " Check Dolt on server: ssh ${HETZNER_HOST} \"docker ps | grep dolt\"" - fi +# ─── 1. Citadel API (HARD BLOCK) ──────────────────────────────────────── +# Validate response body contains "citadel", not just HTTP 200. +# The Flutter catch-all returns 200 with HTML for any path — checking +# status code alone produces false positives. +CITADEL_BODY=$(curl -s --max-time 5 "$CITADEL_HEALTH" 2>/dev/null || echo "") +if echo "$CITADEL_BODY" | grep -q '"citadel"'; then + pass "Citadel API" +else + fail "Citadel API unreachable or returning wrong content" + log " Response: ${CITADEL_BODY:0:80}" + log " No task management = no work. Fix before proceeding." + log " Check: ssh ${HETZNER_HOST} \"docker ps | grep api\"" fi -# ─── 3. MCP Agent Mail ────────────────────────────────────────────────────── -# Agent Mail runs on Hetzner, accessed via HTTPS through Cloudflare/nginx. - +# ─── 2. Agent Mail (HARD BLOCK) ───────────────────────────────────────── if curl -s --connect-timeout 5 "$AGENT_MAIL_HEALTH" 2>/dev/null | grep -q "alive"; then - pass "Agent Mail (${AGENT_MAIL_HEALTH})" + pass "Agent Mail" else - fail "Agent Mail is unreachable" + fail "Agent Mail unreachable" + log " No coordination layer = no multi-agent safety. Fix before proceeding." log " Check: ssh ${HETZNER_HOST} \"cd /opt/mcp_agent_mail && docker compose -f docker-compose.prod.yml logs --tail 20\"" - log " Restart: ssh ${HETZNER_HOST} \"cd /opt/mcp_agent_mail && docker compose -f docker-compose.prod.yml down && docker compose -f docker-compose.prod.yml up -d\"" fi -# ─── 4. Git Hooks Path ───────────────────────────────────────────────────── -# Ensure core.hookspath points to .claude/hooks/ (tracked, works in worktrees). -# .beads/hooks/ is gitignored and doesn't exist in worktrees → no hooks run. - -HOOKS_PATH=$(cd /c/Dev/Desk_Command_Center && git config core.hookspath 2>/dev/null || echo "") +# ─── 3. Git Hooks Path ────────────────────────────────────────────────── +HOOKS_PATH=$(cd "$PROJECT_DIR" && git config core.hookspath 2>/dev/null || echo "") if [ "$HOOKS_PATH" = ".claude/hooks" ]; then - pass "Git hooks path (.claude/hooks — tracked, worktree-safe)" + pass "Git hooks path (.claude/hooks)" else warn "Git hooks path is '${HOOKS_PATH:-}' — fixing to .claude/hooks" - cd /c/Dev/Desk_Command_Center && git config core.hookspath .claude/hooks - pass "Git hooks path fixed to .claude/hooks" + cd "$PROJECT_DIR" && git config core.hookspath .claude/hooks + pass "Git hooks path fixed" fi -# ─── 5. Abandoned Worktree Detection ────────────────────────────────────── -# Scan for worktrees that might be leftovers from crashed agent sessions. -# Warning only — don't auto-delete (might have uncommitted work). - +# ─── 4. Abandoned Worktree Detection ──────────────────────────────────── ABANDONED=() while IFS= read -r wt_line; do wt_path=$(echo "$wt_line" | awk '{print $1}') wt_branch=$(echo "$wt_line" | awk '{print $2}' | tr -d '[]') - - # Skip bare repo and main worktree - if [ "$wt_path" = "/c/Dev/Desk_Command_Center" ] || [ "$wt_path" = "/c/Dev/Desk_Command_Center-main" ]; then - continue - fi - - # Skip if it looks like a known pattern but check age + if [ "$wt_path" = "$PROJECT_DIR" ]; then continue; fi if [ -d "$wt_path" ]; then - # Check last commit time in the worktree LAST_COMMIT=$(cd "$wt_path" 2>/dev/null && git log -1 --format=%ct 2>/dev/null || echo "0") NOW=$(date +%s) AGE_HOURS=$(( (NOW - LAST_COMMIT) / 3600 )) - if [ "$AGE_HOURS" -gt 24 ]; then - ABANDONED+=("$wt_path (branch: $wt_branch, last commit: ${AGE_HOURS}h ago)") + ABANDONED+=("$wt_path (branch: $wt_branch, ${AGE_HOURS}h ago)") fi fi -done < <(cd /c/Dev/Desk_Command_Center && git worktree list 2>/dev/null | grep -v "^$") +done < <(cd "$PROJECT_DIR" && git worktree list 2>/dev/null | grep -v "^$") if [ ${#ABANDONED[@]} -gt 0 ]; then warn "Found ${#ABANDONED[@]} potentially abandoned worktree(s):" for wt in "${ABANDONED[@]}"; do log " $wt" done - log " To clean up: git worktree remove && git branch -d " - log " These may contain uncommitted work from crashed agent sessions." fi -# ─── 6. Claude Heartbeat to Bridge ────────────────────────────────────────── -# DCC-specific: send heartbeat to the Pi5 bridge for status display. - -BRIDGE_URL="http://192.168.50.24:8080" -AGENT_NAME="" -if [ -f "${CLAUDE_PROJECT_DIR:-.}/.agent-identity" ]; then - AGENT_NAME=$(cat "${CLAUDE_PROJECT_DIR:-.}/.agent-identity" 2>/dev/null | tr -d '[:space:]') -fi -if [ -z "$AGENT_NAME" ]; then - AGENT_NAME="Claude-$$" +# ─── 5. Canonical Slash Commands Sync ─────────────────────────────────── +# Sync command files from standards/.claude/commands/ into this project's +# .claude/commands/. Convention: any file in standards/.claude/commands/ +# is canonical — meant to be available in every DW project. Updates +# propagate to all projects at next SessionStart, OR when /refresh-context +# is invoked in a long-running session (since /refresh-context invokes this +# preflight). Project-local-only commands (like /work) live in +# /.claude/commands/ but have no canonical counterpart, so the +# sync never touches them. + +CANONICAL_CMDS="/c/Dev/DifferentWire/standards/.claude/commands" +PROJECT_CMDS="$PROJECT_DIR/.claude/commands" + +if [ -d "$CANONICAL_CMDS" ]; then + mkdir -p "$PROJECT_CMDS" + synced=0 + installed=0 + # Guard against `set -e` aborting on the cmp/cp non-zero returns + for canonical in "$CANONICAL_CMDS"/*.md; do + [ -f "$canonical" ] || continue + name=$(basename "$canonical") + local="$PROJECT_CMDS/$name" + if [ ! -f "$local" ]; then + cp "$canonical" "$local" && installed=$((installed + 1)) || true + elif ! cmp -s "$canonical" "$local"; then + cp "$canonical" "$local" && synced=$((synced + 1)) || true + fi + done + if [ "$installed" -gt 0 ] || [ "$synced" -gt 0 ]; then + pass "Canonical commands: $installed installed, $synced updated" + else + pass "Canonical commands: up to date" + fi +else + warn "Canonical commands dir not found at $CANONICAL_CMDS — standards repo not at canonical path?" fi -curl -sf --max-time 3 -X POST "$BRIDGE_URL/api/claude/heartbeat" \ - -H "Content-Type: application/json" \ - -d "{\"agent\": \"$AGENT_NAME\", \"task\": \"Session starting\", \"program\": \"claude-code\"}" \ - >/dev/null 2>&1 && pass "Claude heartbeat sent ($AGENT_NAME)" || warn "Claude heartbeat failed (non-blocking)" - -# ─── 7. Session State (Compaction Recovery) ────────────────────────────────── -# If an agent session is active, output its state. This output becomes a -# system reminder that survives context compaction, allowing the agent to -# recover its working context (current epic, task, branch, budget). - -# Resolve script path relative to this file (works in any worktree) +# ─── 6. Session State Recovery ─────────────────────────────────────────── PREFLIGHT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SESSION_STATE_SCRIPT="$PREFLIGHT_DIR/session-state.sh" -SESSION_STATE_FILE="/c/Dev/Desk_Command_Center/.claude/agent-session.json" +SESSION_STATE_FILE="$PROJECT_DIR/.claude/agent-session.json" if [ -f "$SESSION_STATE_FILE" ] && [ -f "$SESSION_STATE_SCRIPT" ]; then SESSION_OUTPUT=$(bash "$SESSION_STATE_SCRIPT" read 2>/dev/null || true) @@ -257,29 +148,18 @@ if [ -f "$SESSION_STATE_FILE" ] && [ -f "$SESSION_STATE_SCRIPT" ]; then log " $line" done <<< "$SESSION_OUTPUT" log "" - log " This session state was recovered from disk." - log " If /work was invoked, resume your current task." - log " If this is a new interactive session, ignore this — run" - log " 'bash .claude/hooks/session-state.sh reset' to clear stale state." - log "" fi fi -# ─── Result ────────────────────────────────────────────────────────────────── - +# ─── Result ────────────────────────────────────────────────────────────── log "" if [ "$errors" -gt 0 ]; then log "═══ BLOCKED: $errors check(s) failed ═══" - log "Fix the issues above, then retry. Agents must not operate without" - log "Beads (task coordination) and Agent Mail (file reservation)." + log "Agents must not operate without Citadel AND Agent Mail." log "" exit 2 fi -if [ -n "$ACTIVE_PORT" ]; then - log " Export: BD_DSN=root:@tcp(127.0.0.1:${ACTIVE_PORT})/Desk_Command_Center" -fi - log "═══ All checks passed — session ready ═══" log "" exit 0