From 5efcedf9ba15d771a2f58f5d359e9c9c10171cd8 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 6 Jun 2026 14:01:05 +0200 Subject: [PATCH] feat(statusline): seguimiento de objetivo + fase por terminal Cada terminal muestra su objetivo (color estable por session_id) y la fase de trabajo actual, para distinguir sesiones y saber cuando algo esta hecho. - statusline.sh: linea 0 con objetivo (izq, color por sesion) + fase (der) con el separador estandar; 9 fases (investigando, planificando, haciendo, testeando, puliendo, iterando, pendiente_revision, bloqueado, hecho) con icono, color y etiqueta. Purga de goal files de sesiones muertas (>7 dias). - hooks/goal_tracker.sh (UserPromptSubmit): fija el objetivo leyendo el prompt del usuario ("objetivo: ...", "objetivo: clear" lo borra); si no, informa el estado actual al modelo. - hooks/goal_phase_eval.sh (Stop): al terminar el turno lanza el worker en background, sin bloquear. - hooks/goal_phase_worker.sh: clasifica la fase con ask_llm (haiku, API directa, nunca claude -p) usando la peticion del usuario + la ultima respuesta del asistente. Solo reevalua si el turno tuvo trabajo real (tool_use); en charla pura no toca la fase ni gasta llamada. - settings.json: registra los hooks UserPromptSubmit y Stop. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/goal_phase_eval.sh | 26 ++++++++ .claude/hooks/goal_phase_worker.sh | 101 +++++++++++++++++++++++++++++ .claude/hooks/goal_tracker.sh | 54 +++++++++++++++ .claude/settings.json | 16 +++++ .claude/statusline.sh | 57 ++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100755 .claude/hooks/goal_phase_eval.sh create mode 100755 .claude/hooks/goal_phase_worker.sh create mode 100755 .claude/hooks/goal_tracker.sh diff --git a/.claude/hooks/goal_phase_eval.sh b/.claude/hooks/goal_phase_eval.sh new file mode 100755 index 0000000..8c7df41 --- /dev/null +++ b/.claude/hooks/goal_phase_eval.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Stop hook: tras cada respuesta del asistente, dispara (en background) la +# clasificacion de la fase de la tarea. Lee la ultima respuesta del transcript, +# la clasifica con ask_llm (haiku) y escribe el resultado en el goal JSON de la +# sesion. El statusline lo pinta en el siguiente render. +# +# No bloquea el cierre del turno: el trabajo pesado va al worker en background. + +INPUT=$(cat) +SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) +TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) + +[ -z "$SID" ] && exit 0 + +F="$HOME/.claude/goals/${SID}.json" +# Solo si esta terminal tiene un objetivo fijado. +[ -f "$F" ] || exit 0 +GOAL=$(jq -r '.goal // ""' "$F" 2>/dev/null) +[ -z "$GOAL" ] && exit 0 + +[ -z "$TRANSCRIPT" ] && exit 0 +[ -f "$TRANSCRIPT" ] || exit 0 + +# Lanzar clasificacion en background; el hook retorna de inmediato. +nohup bash "$HOME/.claude/hooks/goal_phase_worker.sh" "$SID" "$TRANSCRIPT" "$F" >/dev/null 2>&1 & +exit 0 diff --git a/.claude/hooks/goal_phase_worker.sh b/.claude/hooks/goal_phase_worker.sh new file mode 100755 index 0000000..6d835b0 --- /dev/null +++ b/.claude/hooks/goal_phase_worker.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Worker del Stop hook (corre en background). Clasifica la fase de la tarea a +# partir de la ultima respuesta del asistente y la escribe en el goal JSON. +# +# Args: +# +# Usa ask_llm (grupo claude-direct, API directa, arranque 0) con haiku. NUNCA +# usa `claude -p` (lento, cold start). Ver regla llm_invocation.md del registry. + +SID="$1" +TRANSCRIPT="$2" +F="$3" + +PY="$HOME/fn_registry/python/.venv/bin/python3" +ASK="$HOME/fn_registry/python/functions/core/ask_llm.py" + +[ -x "$PY" ] || exit 0 +[ -f "$ASK" ] || exit 0 +[ -f "$F" ] || exit 0 +[ -f "$TRANSCRIPT" ] || exit 0 + +GOAL=$(jq -r '.goal // ""' "$F" 2>/dev/null) +[ -z "$GOAL" ] && exit 0 + +# Una sola pasada de abajo a arriba sobre el transcript del turno actual: +# - captura la ultima respuesta de texto del asistente (para clasificar) +# - detecta si en este turno hubo TRABAJO REAL (algun tool_use: edits, bash...) +# El recorrido se detiene al llegar al prompt humano que abrio el turno. +# Si el turno fue solo charla (sin tool_use), NO se reevalua la fase ni se gasta +# una llamada al modelo: la fase se queda como estaba. +LAST="" +USER_MSG="" +HAS_WORK=0 +while IFS= read -r line; do + t=$(printf '%s' "$line" | jq -r '.type // empty' 2>/dev/null) + if [ "$t" = "assistant" ]; then + if [ -z "$LAST" ]; then + txt=$(printf '%s' "$line" | jq -r '(.message.content // [])[]? | select(.type=="text") | .text' 2>/dev/null) + [ -n "$txt" ] && LAST="$txt" + fi + if printf '%s' "$line" | jq -e '(.message.content // [])[]? | select(.type=="tool_use")' >/dev/null 2>&1; then + HAS_WORK=1 + fi + elif [ "$t" = "user" ]; then + ctype=$(printf '%s' "$line" | jq -r '.message.content | type' 2>/dev/null) + if [ "$ctype" = "string" ]; then + USER_MSG=$(printf '%s' "$line" | jq -r '.message.content' 2>/dev/null) + break + fi + if ! printf '%s' "$line" | jq -e '(.message.content // [])[]? | select(.type=="tool_result")' >/dev/null 2>&1; then + USER_MSG=$(printf '%s' "$line" | jq -r '(.message.content // [])[]? | select(.type=="text") | .text' 2>/dev/null) + break + fi + fi +done < <(tac "$TRANSCRIPT") + +# Charla pura (sin trabajo en el turno): no tocar la fase. +[ "$HAS_WORK" = "0" ] && exit 0 + +LAST=$(printf '%s' "$LAST" | tail -c 4000) +[ -z "$LAST" ] && exit 0 +# La peticion del usuario marca la INTENCION del turno (ej. "testealo" -> testeando). +USER_MSG=$(printf '%s' "$USER_MSG" | tail -c 1500) + +SYS="Eres un clasificador de fase de tarea. Recibes el objetivo, la ULTIMA PETICION DEL USUARIO (marca la intencion del turno) y la ULTIMA RESPUESTA DEL ASISTENTE. Clasifica la actividad de ESTE turno con UNA sola palabra de esta lista exacta, sin nada mas: investigando planificando haciendo testeando puliendo iterando pendiente_revision bloqueado hecho. Definiciones: investigando=leyendo o explorando codigo para entender; planificando=disenando el enfoque sin ejecutar todavia; haciendo=implementando cambios; testeando=corriendo tests, probando o validando tecnicamente (si el usuario pide 'testea/prueba/valida', es testeando); puliendo=retoques finales sobre algo ya casi listo; iterando=ciclo continuo de ajustes pequenos; pendiente_revision=el asistente espera que el humano revise o decida algo importante; bloqueado=no puede avanzar por un error o falta de informacion; hecho=el objetivo esta claramente terminado y verificado. La peticion del usuario pesa: refleja que se pidio hacer en este turno. Usa 'hecho' SOLO si el trabajo esta completo y confirmado, nunca si queda algo pendiente." + +PROMPT="OBJETIVO DE LA TAREA: ${GOAL} + +ULTIMA PETICION DEL USUARIO: +${USER_MSG} + +ULTIMA RESPUESTA DEL ASISTENTE: +${LAST} + +Responde la fase actual (una sola palabra de la lista):" + +RAW=$("$PY" "$ASK" --model claude-haiku-4-5-20251001 --system "$SYS" "$PROMPT" 2>/dev/null | tr '[:upper:]' '[:lower:]') +[ -z "$RAW" ] && exit 0 + +# Normalizar la respuesta a un slug canonico (tolerante a verbosidad/acentos). +case "$RAW" in + *pendiente*revis*|*revis*) PHASE=pendiente_revision ;; + *investig*) PHASE=investigando ;; + *planific*) PHASE=planificando ;; + *test*) PHASE=testeando ;; + *puli*) PHASE=puliendo ;; + *iter*) PHASE=iterando ;; + *bloque*) PHASE=bloqueado ;; + *hecho*|*complet*|*termin*|*done*) PHASE=hecho ;; + *hacien*|*implement*) PHASE=haciendo ;; + *) exit 0 ;; +esac + +# Escribir la fase preservando el resto del JSON. +TMP="${F}.tmp.$$" +if jq --arg p "$PHASE" '.phase=$p' "$F" > "$TMP" 2>/dev/null; then + mv "$TMP" "$F" +else + rm -f "$TMP" +fi +exit 0 diff --git a/.claude/hooks/goal_tracker.sh b/.claude/hooks/goal_tracker.sh new file mode 100755 index 0000000..2e077ce --- /dev/null +++ b/.claude/hooks/goal_tracker.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# UserPromptSubmit hook del sistema de objetivo+fase por terminal. +# +# Dos funciones: +# 1) Si tu prompt empieza por "objetivo:" (o "meta:" / "goal:"), fija el objetivo +# de esta terminal leyendolo DIRECTAMENTE de tu prompt, sin intervencion del +# modelo. "objetivo: clear" (o -, none, borrar, quitar, reset) lo elimina. +# 2) En cualquier otro caso, inyecta el estado actual (goal+phase) como contexto +# para que el modelo sepa donde esta el archivo y que objetivo hay vigente. +# +# La FASE no la toca este hook: la mantiene el Stop hook (goal_phase_eval.sh). + +INPUT=$(cat) +SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) +[ -z "$SID" ] && exit 0 + +F="$HOME/.claude/goals/${SID}.json" +PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // ""' 2>/dev/null) + +# 1) Fijar/limpiar el objetivo leyendo el prompt del usuario. +GOAL_LINE=$(printf '%s' "$PROMPT" | grep -ioE '^[[:space:]]*(objetivo|meta|goal)[[:space:]]*:[[:space:]]*.+' | head -1) +if [ -n "$GOAL_LINE" ]; then + NEWGOAL=$(printf '%s' "$GOAL_LINE" | sed -E 's/^[^:]*:[[:space:]]*//; s/[[:space:]]+$//') + case "$NEWGOAL" in + -|clear|none|borrar|quitar|reset) + rm -f "$F" + echo "GOAL-TRACKER: objetivo de esta terminal borrado. El statusline deja de mostrar la linea de objetivo." + exit 0 + ;; + esac + if [ -f "$F" ]; then + PH=$(jq -r '.phase // "planificando"' "$F" 2>/dev/null) + else + PH="planificando" + fi + TMP="${F}.tmp.$$" + if jq -n --arg g "$NEWGOAL" --arg p "$PH" '{goal:$g, phase:$p}' > "$TMP" 2>/dev/null; then + mv "$TMP" "$F" + else + rm -f "$TMP" + fi + echo "GOAL-TRACKER: objetivo fijado desde tu prompt -> \"$NEWGOAL\" (phase=$PH). El statusline ya lo muestra; la fase la mantiene el Stop hook automaticamente." + exit 0 +fi + +# 2) Informativo: estado actual para el modelo. +if [ -f "$F" ]; then + G=$(jq -r '.goal // ""' "$F" 2>/dev/null) + P=$(jq -r '.phase // ""' "$F" 2>/dev/null) + echo "GOAL-TRACKER: file=$F | goal=\"$G\" phase=\"$P\". La fase la mantiene el Stop hook automaticamente — NO escribas la fase. El usuario fija el objetivo escribiendo \"objetivo: \". Si redefine la tarea en lenguaje natural sin ese prefijo, actualiza \"goal\" en ese JSON leyendo su prompt." +else + echo "GOAL-TRACKER: file=$F (sin objetivo aun). El usuario fija el objetivo de la terminal escribiendo \"objetivo: \" (lo captura este hook directo de su prompt). Si describe una tarea clara sin ese prefijo, crea {\"goal\":\"\",\"phase\":\"planificando\"} leyendo su prompt. Sin objetivo, ignora." +fi +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index d2c8008..ff8f798 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,6 +13,22 @@ "Write(.git/**)" ] }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/goal_tracker.sh" } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/goal_phase_eval.sh" } + ] + } + ] + }, "statusLine": { "type": "command", "command": "~/.claude/statusline.sh", diff --git a/.claude/statusline.sh b/.claude/statusline.sh index 49fd700..2209c2a 100755 --- a/.claude/statusline.sh +++ b/.claude/statusline.sh @@ -23,6 +23,11 @@ MODEL=$(echo "$INPUT" | jq -r '.model.display_name // "Unknown"') CONTEXT_PCT=$(echo "$INPUT" | jq -r '.context_window.used_percentage // 0' | xargs printf "%.0f") CONTEXT_TOTAL=$(echo "$INPUT" | jq -r '.context_window.context_window_size // 200000') CURRENT_DIR=$(echo "$INPUT" | jq -r '.workspace.current_dir // "~"' | sed "s|$HOME|~|") +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""') + +# Purga: borra goal files de sesiones muertas (no tocados en >7 dias). El worker +# refresca el mtime en cada respuesta, asi que las sesiones vivas nunca caen. +find "$HOME/.claude/goals" -maxdepth 1 -name '*.json' -mtime +7 -delete 2>/dev/null # Tokens de entrada y salida (current_usage puede ser null antes del primer API call) INPUT_TOKENS=$(echo "$INPUT" | jq -r '.context_window.current_usage.input_tokens // 0') @@ -91,6 +96,34 @@ if git rev-parse --git-dir > /dev/null 2>&1; then GIT_STATUS=$(echo "$GIT_STATUS" | sed 's/ $//') fi +# Color estable por sesión (hash del session_id → paleta ANSI 256 legible). +# Cada terminal mantiene su color toda su vida; distinto entre terminales. +goal_color() { + local sid="$1" + local palette=(39 45 51 75 81 114 120 156 183 210 215 222 213 159 228) + local h + h=$(printf '%s' "$sid" | cksum | cut -d' ' -f1) + local idx=$(( h % ${#palette[@]} )) + printf '\033[1;38;5;%dm' "${palette[$idx]}" +} + +# Fase de trabajo → icono | color ANSI | etiqueta visible. +# El slug (clave) lo escribe el agente del Stop hook; aqui se mapea a su estilo. +phase_style() { + case "$1" in + investigando) printf '🔎|36|investigando' ;; + planificando) printf '📋|34|planificando' ;; + haciendo) printf '🔨|33|haciendo' ;; + testeando) printf '🧪|35|testeando' ;; + puliendo) printf '✨|95|puliendo detalles' ;; + iterando) printf '🔁|94|iterando' ;; + pendiente_revision) printf '👀|93|pendiente de revisión' ;; + bloqueado) printf '⛔|31|bloqueado' ;; + hecho) printf '✅|32|hecho' ;; + *) printf "•|90|$1" ;; + esac +} + # Función para crear barra de progreso progress_bar() { local pct=$1 @@ -251,6 +284,30 @@ fi # 6. Directorio actual LINE2="${LINE2} ${GRAY}│${RESET} ${BLUE}${CURRENT_DIR}${RESET}" +# ===== LÍNEA 0: Objetivo (izq) + Fase (der) ===== +# Solo si la sesión tiene archivo de objetivo con goal no vacío. +GOAL_FILE="$HOME/.claude/goals/${SESSION_ID}.json" +if [ -n "$SESSION_ID" ] && [ -f "$GOAL_FILE" ]; then + GOAL=$(jq -r '.goal // ""' "$GOAL_FILE" 2>/dev/null) + PHASE=$(jq -r '.phase // ""' "$GOAL_FILE" 2>/dev/null) + if [ -n "$GOAL" ]; then + GC=$(goal_color "$SESSION_ID") + LEFT_PLAIN="🎯 ${GOAL}" + LEFT="${GC}${LEFT_PLAIN}${RESET}" + + LINE0="${LEFT}" + if [ -n "$PHASE" ]; then + PS=$(phase_style "$PHASE") + PICON="${PS%%|*}" + REST="${PS#*|}" + PCOL="${REST%%|*}" + PLABEL="${REST#*|}" + LINE0="${LINE0} ${GRAY}│${RESET} \033[1;${PCOL}m${PICON} ${PLABEL}${RESET}" + fi + echo -e "$LINE0" + fi +fi + # Imprimir resultado (2 líneas) echo -e "$LINE1" echo -e "$LINE2"