From 4e8b5af6c4f2d8c13ad976a62a0427502af69434 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 14 May 2026 02:06:44 +0200 Subject: [PATCH] feat(infra): auto-commit con 29 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 13 + .claude/commands/fn_claude.md | 35 ++ .claude/rules/INDEX.md | 1 + .../rules/function_growth_and_self_docs.md | 115 ++++ .claude/rules/registry_calls.md | 15 + .claude/scripts/hook_call_monitor.sh | 17 +- .claude/scripts/hook_capabilities_inject.sh | 121 ++++ .claude/scripts/hook_fn_match.sh | 133 +++++ CHANGELOG.md | 38 ++ bash/functions/infra/build_cpp_windows.md | 2 +- .../infra/deploy_cpp_exe_to_windows.md | 2 +- bash/functions/infra/e2e_run_cpp_windows.md | 2 +- .../infra/is_cpp_app_running_windows.md | 70 +++ .../infra/is_cpp_app_running_windows.sh | 45 ++ .../functions/infra/launch_cpp_app_windows.md | 70 +++ .../functions/infra/launch_cpp_app_windows.sh | 42 ++ bash/functions/pipelines/compile_cpp_app.md | 2 +- .../pipelines/redeploy_cpp_app_windows.md | 75 +++ .../pipelines/redeploy_cpp_app_windows.sh | 92 +++ cmd/fn/doctor.go | 21 +- cmd/fn/main.go | 6 +- cmd/fn/match.go | 553 ++++++++++++++++++ .../0087-capability-discovery-acceleration.md | 101 ++++ ...0004-telemetry-driven-capability-growth.md | 83 +++ docs/adr/README.md | 3 + docs/capabilities/INDEX.md | 1 + docs/capabilities/cpp-windows.md | 80 +++ docs/diary/2026-05-14.md | 103 ++++ functions/infra/emit_capabilities_md.go | 318 ++++++++++ 29 files changed, 2148 insertions(+), 11 deletions(-) create mode 100644 .claude/rules/function_growth_and_self_docs.md create mode 100755 .claude/scripts/hook_capabilities_inject.sh create mode 100755 .claude/scripts/hook_fn_match.sh create mode 100644 bash/functions/infra/is_cpp_app_running_windows.md create mode 100644 bash/functions/infra/is_cpp_app_running_windows.sh create mode 100644 bash/functions/infra/launch_cpp_app_windows.md create mode 100644 bash/functions/infra/launch_cpp_app_windows.sh create mode 100644 bash/functions/pipelines/redeploy_cpp_app_windows.md create mode 100644 bash/functions/pipelines/redeploy_cpp_app_windows.sh create mode 100644 cmd/fn/match.go create mode 100644 dev/issues/0087-capability-discovery-acceleration.md create mode 100644 docs/adr/0004-telemetry-driven-capability-growth.md create mode 100644 docs/capabilities/cpp-windows.md create mode 100644 docs/diary/2026-05-14.md create mode 100644 functions/infra/emit_capabilities_md.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9ed0d2a5..f11b3b03 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,6 +2,19 @@ Registry personal de codigo reutilizable con busqueda FTS. Diseñado para composicion funcional y agentes. +## Objetivos del registry (Norte) — Issues 0086 + 0087 + +**4 metricas optimizadas por el bucle reactivo** (visibles en Monitor tab del `registry_dashboard`): + +1. **MAXIMIZAR `Reg %`** — porcentaje de calls del agente que golpean una funcion del registry (`function_id != ''`). Cada bash inline o heredoc que reescribe logica baja el ratio. Target: subir cada semana. +2. **MEJORAR uso del registry por Claude** — el agente debe encontrar y usar funciones existentes antes de escribir codigo. Indicadores: `MCP` (mcp/heredoc/fn run) sube; violations baja. Si Claude no encuentra una funcion por busqueda mediocre, mejorar `description`/`tags`/`params_schema` de esa funcion. +3. **ACELERAR tareas comunes via funciones nuevas** — patrones inline repetidos >2 veces -> `fn-constructor` crea la funcion, Claude la usa el siguiente turno. Velocidad medida en pasos (turnos) por tarea. Pattern detection: tab Monitor + `mcp__registry__fn_proposal action="list"`. +4. **PROMOVER COMPOSICIONES A PIPELINES** (issue 0087) — el registry no crece inflando funciones, crece **promoviendo secuencias A→B(→C) que se repiten con exito** a pipelines one-shot. Hoy `bank_login + bank_make_transfer` (2 calls). Manana `bank_transfer_oneshot` (1 call). Misma capacidad, mitad de pasos. Detectado por telemetria de secuencias en `call_monitor`. Una funcion que hace bien UNA cosa NO necesita crecer — lo que crece es el catalogo de composiciones probadas. + +**Auto-discovery zero-second-lookup:** cada `.md` debe ser autosuficiente — `## Ejemplo` lanzable + `## Cuando usarla` + `## Gotchas` (impuras). Descubrir = lanzar, sin segunda lectura. Ver `.claude/rules/function_growth_and_self_docs.md`. + +Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. Ejemplo: un bash heredoc rapido hoy que reinventa logica = penaliza objetivos 1 y 3 manana. + **Dos bases de datos SQLite:** - **registry.db** (raiz) — funciones, tipos, proposals, apps, projects, analysis, vaults, pc_locations. Regenerable con `fn index` (excepto proposals y pc_locations). - **operations.db** (por app en `apps/*/`) — entities, relations, executions, assertions. Datos vivos. diff --git a/.claude/commands/fn_claude.md b/.claude/commands/fn_claude.md index ecd11121..18d2670b 100644 --- a/.claude/commands/fn_claude.md +++ b/.claude/commands/fn_claude.md @@ -6,6 +6,41 @@ description: "Auto-auditoria: verifica que la sesion registra uso de funciones, Comando meta: Claude se audita a si mismo. Verifica que su comportamiento en esta sesion (y las recientes) deja rastro en `call_monitor.operations.db`, detecta gaps reales del registry para el trabajo actual, lanza sub-agentes `fn-constructor` en paralelo para cerrar esos gaps, y verifica que la proxima vez usara las funciones nuevas. +## Objetivos del registry (Norte) — Issues 0086 + 0087 + +Cada corrida de `/fn_claude` optimiza 4 metricas visibles en Monitor tab del `registry_dashboard`: + +1. **MAXIMIZAR `Reg %`** — % de calls con `function_id != ''`. Cada heredoc/bash que reescribe logica baja el ratio. Target: subir cada semana. +2. **MEJORAR uso del registry por Claude** — Claude busca y reusa antes de escribir. `MCP` (mcp/heredoc/fn run) sube, `violations` baja. Si una funcion existe pero Claude no la encuentra, mejorar su `description`/`tags`/`params_schema` (FTS indexa todo). +3. **ACELERAR tareas comunes** — patrones inline repetidos >2x -> `fn-constructor` los convierte en funcion, Claude las usa el siguiente turno. Menos pasos por tarea = mas valor. +4. **PROMOVER COMPOSICIONES A PIPELINES** (issue 0087) — el registry crece **promoviendo secuencias A->B(->C) que se repiten con exito** a pipelines one-shot. Una funcion que hace bien una cosa NO necesita crecer. Pattern detection: `call_monitor sequences --detect --propose` (cron 6h activo) + tab `Promotion candidates` del dashboard. + +Si `/fn_claude` no mueve estas 4 metricas, no esta haciendo su trabajo. + +## Infraestructura de discovery activa (issue 0087) + +Cada turno tienes capacidades ya cargadas SIN buscar. Si no las usas estas pagando el coste de FTS innecesariamente: + +| Senal | Donde | Que hacer | +|---|---|---| +| Linea `CAPABILITIES (cache 1h): TOP: ... FRESH (7d): ... PIPELINES: ...` en cada UserPromptSubmit | hook `hook_capabilities_inject.sh` | Antes de buscar con `mcp__registry__fn_search`, mira si la funcion que necesitas esta en TOP/FRESH/PIPELINES. Si si, ve directo a `fn show ` (1 read) o `./fn run ` (0 reads). | +| `FUZZY-MATCH (issue 0087): your Bash command may already be a function. USE: ./fn run -> ` aparecido mid-flight | hook `hook_fn_match.sh` (PreToolUse, Bash matcher) | El hook detecto que tu Bash inline coincide con una funcion del registry. **NO ignores el reminder** — abandona el inline, llama a `./fn run ` o `mcp__registry__fn_run id=""`. Si crees que la sugerencia es falso positivo, justifica brevemente antes de seguir inline (queda en violations). | +| Hint AUSENTE para una query corta (`rsi sma` < 3 tokens) | threshold `raw_score >= 4.0` no alcanzado | NO interpretar la ausencia de hint como "no existe funcion". Usa `mcp__registry__fn_search` con query mas rica (3+ tokens del dominio). | +| Falso positivo conocido: `agent` token | `robots.txt user-agent` matchea `agent_scaffold` | Ignora el reminder y sigue. Cost = 1 reminder ignorable. | + +## Como combinar la 3 senales para minimizar pasos + +1. **User prompt llega** -> lees `CAPABILITIES` line. Si la tarea encaja claramente con TOP/FRESH -> usa directo. +2. **Vas a escribir Bash inline** -> el hook PreToolUse lo intercepta. Si dispara FUZZY-MATCH -> usa `./fn run `. +3. **No hay match y necesitas codigo** -> `mcp__registry__fn_search` con 3+ tokens. Si sigue sin hit -> delega a `fn-constructor` (no escribas inline). Patron repetido detectado por `call_monitor sequences` se promovera a pipeline en proximas iteraciones. + +## Las 4 metricas norte (donde vigilarlas) + +- `Reg %` (Monitor KPI) — % calls con function_id no vacio. Sube cuando el registry se usa. +- `MCP` (Monitor KPI) — count calls con tools registry-aware (mcp*/heredoc*/fn_cli_run). Adopcion de patrones canonicos. +- `Errors` / `Violations` (Monitor KPI) — bajan cuando el bucle cierra. +- `Failed Functions` (Monitor sub-tab) — registry-functions que fallaron: diagnostico de bugs prioritarios. + Issue 0085 fase autocompleta. Reemplaza el flujo manual de "veo un patron, decido si extraer, escribo proposal, espero humano, fn-mejorador genera, fn-orquestador opera". Con `/fn_claude` Claude hace todo eso solo, **autonomamente para si mismo**. --- diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index 2d6512fa..ddbcedd1 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -33,3 +33,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente. | 27 | [registry_calls.md](registry_calls.md) | Patrones canonicos para invocar funciones del registry (MCP inspect / MCP run / heredoc compose), antipatrones, excepciones, telemetria. Issue 0085 | | 28 | [delegation.md](delegation.md) | Si vas a escribir logica reutilizable inline -> spawn fn-constructor inmediato + tag de grupo + usar en mismo turno. Issue 0086 | | 29 | [capability_groups.md](capability_groups.md) | Tags planos + paginas madre `docs/capabilities/.md` para desbloquear clusters de funciones en un read. Issue 0086 | +| 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 | diff --git a/.claude/rules/function_growth_and_self_docs.md b/.claude/rules/function_growth_and_self_docs.md new file mode 100644 index 00000000..dcacd95a --- /dev/null +++ b/.claude/rules/function_growth_and_self_docs.md @@ -0,0 +1,115 @@ +## Function growth + self-documenting capability + +Dos doctrinas hermanas. Una define **como deben ser** las funciones (auto-descubribles y lanzables sin segunda lectura). La otra define **como crece** el registry (no inflando funciones — promoviendo composiciones a pipelines). + +Issue 0087. + +--- + +### Parte A — `.md` autosuficiente (contrato OBLIGATORIO) + +Cuando Claude (o un humano) encuentra una funcion via FTS / fuzzy match / capability page / TOP block, el `.md` debe bastar para **lanzarla sin abrir el codigo**. Esto es lo que hace que descubrir = lanzar y elimina el coste del second lookup. + +**Secciones obligatorias** en cada `.md` del registry (functions + pipelines + types con uso practico): + +| Seccion | Contenido | Tamaño | +|---|---|---| +| Frontmatter | `name`, `signature`, `params` (con `desc` por param), `output`, `tags`, `uses_functions`, etc. Lo de hoy. | — | +| `## Ejemplo` | Bloque de codigo lanzable con args **concretos**. Copiar+pegar produce ejecucion real. NO placeholders abstractos. | 3-10 lineas | +| `## Cuando usarla` | 1-2 frases con triggers: "cuando hagas X / antes de Y / si necesitas Z". Verbos imperativos. Ayuda al fuzzy match y a Claude a saber sin leer el codigo. | 1-3 lineas | +| `## Gotchas` | Problemas conocidos / no-go cases. Obligatoria para funciones impuras o con efectos (Windows-side, red, FS write, GPU). Omisible para funciones puras triviales. | 0-5 puntos | +| `## Capability growth log` | Solo SI la funcion ha crecido. Una linea por version: `v1.1.0 (YYYY-MM-DD) — anade --build flag para skip build`. No se rellena en v1.0.0. | crece con el tiempo | + +**Anti-patrones del .md:** + +- Ejemplo con ``, `` placeholders abstractos — NO. Ejemplos con valores reales (`registry_dashboard`, `/home/lucas/...`). +- "Cuando usarla" vacio o "ver descripcion arriba" — NO. Frase nueva con trigger explicito. +- `notes` lleno + `## Gotchas` vacio cuando la funcion tiene efectos — mover de `notes` a `## Gotchas`. +- Capability growth log inventado (sin que la funcion haya cambiado) — NO. Solo se rellena cuando hay version bump real. + +**Verificacion** (TBD: convertir a check de `fn doctor`): cada .md de `functions/`/`pipelines/` debe tener `## Ejemplo` y `## Cuando usarla`. `## Gotchas` obligatoria solo si `purity: impure`. `## Capability growth log` libre. + +--- + +### Parte B — Crecimiento por composicion (no por inflado) + +**Principio:** una funcion que hace bien UNA cosa NO necesita crecer. Anadir params "por si acaso" la hace peor (Inner Platform Effect). Lo que crece es el **registry**: pipelines nuevos que componen funciones existentes. + +#### Ejemplo del principio + +- **Hoy:** Claude para hacer una transferencia bancaria llama `bank_login` -> `bank_list_accounts` -> `bank_make_transfer`. 3 calls, 3 decisiones, 3 puntos de fallo. +- **Manana:** pipeline `bank_transfer_oneshot(account, amount, target)` que compone las 3 internamente. 1 call, 1 decision. + +Misma capacidad, 3x menos pasos. **Esto es lo que multiplica la velocidad de Claude**, no anadir flags a `bank_login`. + +#### Como se promueve una composicion + +Senal detectable en `call_monitor.operations.db`: secuencia A→B(→C) con + +- **Mismo session_id**. +- **Intervalo entre calls < N segundos** (default 30s). +- **Occurrences > K** (default 5) en ventana de **D dias** (default 30). +- **Success rate > S** (default 0.9 — falla < 10%). +- **No existe ya un pipeline** que la cubra (validar con FTS sobre `uses_functions`). + +Cuando se cumple → **proposal `new_pipeline`** con evidencia (sequence_ids, session_ids, occurrence count). Humano (o `fn-orquestador` autonomo) decide promover. + +#### Implementacion (issue 0087 tanda A) + +- `call_monitor sequences --detect` subcomando: escanea `calls` table, agrupa por session+window, computa secuencias, upserta en tabla `function_sequences`. +- Cron diario que ejecuta el detector + genera proposals automaticas. +- Visible en Monitor tab del `registry_dashboard`: sub-tab "Promotion candidates". + +#### Cuando SI inflar una funcion + +Casos legitimos para anadir feature a una funcion existente: + +1. **Generalizar firma** sin romper consumidores (anadir param opcional con default sensato). +2. **Mejor manejo de error** (mensajes mas claros, retry sensible). +3. **Default mas inteligente** (autodetectar lo que antes era arg obligatorio). +4. **Eliminar gotcha conocido** (fix de bug que estaba en `## Gotchas`). + +NO infles para casos hipoteticos. NO anadas params "por flexibilidad". Si dudas, separa la responsabilidad en una funcion nueva o un pipeline. + +#### Capability growth log — cuando se rellena + +- Se rellena **solo cuando la funcion crece** (alguno de los 4 casos arriba). +- Cada bump de `version` -> 1 linea en `## Capability growth log` con fecha y resumen 1-frase. +- Una funcion estable de hace 6 meses puede seguir en v1.0.0 sin log: indica madurez, no abandono. +- Telemetria (call_monitor) decide si una funcion estable es huerfana (`calls_90d=0`) o usada-y-buena (`calls_30d>10, error_rate<0.05`). Las primeras se deprecan; las segundas se respetan. + +--- + +### Parte C — Output de discovery + +Cuando un mecanismo de discovery (fuzzy match / FRESH hook / TOP block / capability page) surfacea una funcion, el payload **minimo** es: + +``` + +``` + +Ejemplo concreto: +``` +redeploy_cpp_app_windows_bash_pipelines + ./fn run redeploy_cpp_app_windows registry_dashboard /path/to/app [--build] + use: tras compilar cpp/build/windows, antes de smoke test manual +``` + +Si Claude necesita mas (gotchas, params completos, codigo), un `mcp__registry__fn_show ` adicional. Pero el primer hit ya basta para el 80% de casos. + +--- + +### Parte D — Relacion con otras reglas + +- [[registry_first]] dice CUANDO buscar/usar/delegar. Esta regla dice **COMO** debe ser la funcion para que esa busqueda valga. +- [[ids_naming]] hace ID predictible. Esta regla hace metadata predictible. +- [[delegation]] dice cuando spawnar fn-constructor. Esta regla es lo que fn-constructor debe producir. +- [[capability_groups]] agrupa funciones afines. Las paginas madre de cada grupo deben respetar el mismo contrato self-doc (mejor con su propio ejemplo end-to-end por grupo). + +### Resumen TL;DR + +1. Cada `.md` autosuficiente: Ejemplo + Cuando usarla + Gotchas (si impura) + Growth log (si crecio). +2. Las funciones que hacen bien una cosa NO necesitan crecer. +3. El registry crece **promoviendo composiciones repetidas a pipelines**, no inflando funciones. +4. Telemetria de `call_monitor` detecta secuencias candidatas y abre proposals automaticas. +5. Discovery devuelve siempre: `id + signature + 1-line example`. Resto on-demand. diff --git a/.claude/rules/registry_calls.md b/.claude/rules/registry_calls.md index 01c6a096..85e1339c 100644 --- a/.claude/rules/registry_calls.md +++ b/.claude/rules/registry_calls.md @@ -33,6 +33,21 @@ Casos donde el MCP no aplica y `sqlite3 registry.db` es legitimo: El hook `PreToolUse` (`.claude/scripts/hook_registry_mcp.sh`) ya deja pasar estas excepciones y solo avisa cuando ve `sqlite3 registry.db "SELECT ..."` plano. +### Excepcion: hooks e infraestructura de telemetria (issue 0087) + +Los **hooks** (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, etc.) y los **binarios de infraestructura** que sirven al agente (`fn_match`, `fn doctor`, `call_monitor`) **pueden leer `registry.db` directo** via `sqlite3` o `database/sql` con conexion read-only. NO estan sujetos a la regla MCP-first porque: + +- No son acciones del agente — son inspeccion automatizada del entorno. +- El MCP requiere tool invocation por Claude; un hook no puede invocar tools. +- Latencia objetivo (50-200ms) incompatible con round-trip MCP. + +**Restricciones:** +- SOLO lectura. Conexion debe abrirse con `?mode=ro` o `?_query_only=1`. +- NUNCA escritura a `registry.db` desde hooks. +- Si un hook necesita escribir (cache, telemetria propia), usa su propia DB (`operations.db` del app de hooks, o `~/.fn_hooks/cache.db`). + +Esta excepcion es **explicita y acotada** — no aplica al agente, que sigue regido por la regla MCP-first. + ### Verificacion previa — `fn doctor` Antes de empezar trabajo no trivial sobre el registry, ejecutar `fn doctor` para confirmar que el ecosistema esta sano: diff --git a/.claude/scripts/hook_call_monitor.sh b/.claude/scripts/hook_call_monitor.sh index 540e4111..bb40ed4a 100755 --- a/.claude/scripts/hook_call_monitor.sh +++ b/.claude/scripts/hook_call_monitor.sh @@ -59,15 +59,24 @@ ARGS_HASH=$(printf '%s' "$INPUT" | jq -c '.tool_input // {}' | sha256sum | cut - sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } insert_call() { - local fn_id="$1" tool_used="$2" duration_ms="${3:-0}" - local fn_esc tu_esc ec_esc es_esc sid_esc ah_esc + local fn_id="$1" tool_used="$2" duration_ms="${3:-0}" snippet="${4:-}" + local fn_esc tu_esc ec_esc es_esc sid_esc ah_esc snip_esc + # Politica issue 0087: command_snippet solo se rellena cuando function_id + # esta vacio. Si la call golpea una funcion del registry, su ID y + # tool_used bastan; no duplicamos el comando. + if [ -n "$fn_id" ]; then snippet=""; fi + # Redact common secrets antes de persistir + snippet=$(printf '%s' "$snippet" \ + | sed -E 's/(password|token|secret|api[_-]?key|bearer)([[:space:]]*[=:][[:space:]]*)[^[:space:]]+/\1\2/Ig' \ + | head -c 200) fn_esc=$(sql_escape "$fn_id") tu_esc=$(sql_escape "$tool_used") ec_esc=$(sql_escape "$ERROR_CLASS") es_esc=$(sql_escape "$ERROR_SNIPPET") sid_esc=$(sql_escape "$SESSION_ID") ah_esc=$(sql_escape "$ARGS_HASH") - sqlite3 "$DB" "INSERT INTO calls (session_id, function_id, tool_used, args_hash, duration_ms, success, error_class, error_snippet, ts) VALUES ('$sid_esc','$fn_esc','$tu_esc','$ah_esc',$duration_ms,$SUCCESS,'$ec_esc','$es_esc',$TS);" 2>/dev/null || true + snip_esc=$(sql_escape "$snippet") + sqlite3 "$DB" "INSERT INTO calls (session_id, function_id, tool_used, args_hash, duration_ms, success, error_class, error_snippet, command_snippet, ts) VALUES ('$sid_esc','$fn_esc','$tu_esc','$ah_esc',$duration_ms,$SUCCESS,'$ec_esc','$es_esc','$snip_esc',$TS);" 2>/dev/null || true } insert_code_write() { @@ -204,7 +213,7 @@ case "$TOOL_NAME" in TOOL_USED="sqlite_direct" fi - insert_call "$FN_ID" "$TOOL_USED" + insert_call "$FN_ID" "$TOOL_USED" 0 "$CMD_HEAD" # ---- Violation rules ---- # 1. sqlite3 directo SELECT sobre registry.db (excepto schema/pragma/count/join) diff --git a/.claude/scripts/hook_capabilities_inject.sh b/.claude/scripts/hook_capabilities_inject.sh new file mode 100755 index 00000000..ed2e57eb --- /dev/null +++ b/.claude/scripts/hook_capabilities_inject.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook: inyecta capacidades calientes (TOP/FRESH/PIPELINES) +# del registry como additionalContext en cada turno del usuario. +# +# Cache: ~/.cache/fn_registry/capabilities.txt (TTL 1h). +# Fuente: `./fn doctor capabilities --emit-claude-md` desde la raiz del repo. +# +# NUNCA bloquea: si algo falla, emite contexto vacio y sale 0. + +set -uo pipefail + +CACHE_DIR="${HOME}/.cache/fn_registry" +CACHE_FILE="${CACHE_DIR}/capabilities.txt" +TTL_SECONDS=3600 + +# Resolve registry root (walks up from cwd, fallback CLAUDE_PROJECT_DIR) +resolve_root() { + local d="${PWD}" + while [ "$d" != "/" ]; do + if [ -f "$d/registry.db" ] && [ -x "$d/fn" ]; then + printf '%s' "$d" + return 0 + fi + d=$(dirname "$d") + done + if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -f "${CLAUDE_PROJECT_DIR}/registry.db" ]; then + printf '%s' "${CLAUDE_PROJECT_DIR}" + return 0 + fi + return 1 +} + +# Consume stdin (UserPromptSubmit payload) — we don't need it but keep stdin clean +cat >/dev/null 2>&1 || true + +ROOT=$(resolve_root) || exit 0 +mkdir -p "$CACHE_DIR" 2>/dev/null || exit 0 + +# Cache freshness check +need_refresh=1 +if [ -f "$CACHE_FILE" ]; then + now=$(date +%s) + mtime=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) + age=$((now - mtime)) + if [ "$age" -lt "$TTL_SECONDS" ]; then + need_refresh=0 + fi +fi + +if [ "$need_refresh" -eq 1 ]; then + # Regenerate: call fn doctor capabilities --emit-claude-md and process + raw=$("$ROOT/fn" doctor capabilities --emit-claude-md 2>/dev/null || true) + if [ -z "$raw" ]; then + exit 0 + fi + + # Extract top 5 from each section using awk. + # Sections detected by "## ... Top" / "## ... Fresh" / "## ... Pipelines". + line=$(printf '%s\n' "$raw" | awk ' + BEGIN { sec=""; n_top=0; n_fresh=0; n_pipe=0; } + /^## .*Top 20/ { sec="TOP"; next } + /^## .*Fresh/ { sec="FRESH"; next } + /^## .*Pipelines/ { sec="PIPE"; next } + /^## / { sec=""; next } + /^- `/ { + # extract first backticked token + s = $0 + sub(/^- `/, "", s) + i = index(s, "`") + if (i == 0) next + id = substr(s, 1, i-1) + if (sec == "TOP" && n_top < 5) { tops[n_top++] = id } + if (sec == "FRESH" && n_fresh < 5) { fresh[n_fresh++] = id } + if (sec == "PIPE" && n_pipe < 5) { pipes[n_pipe++] = id } + } + END { + out = "CAPABILITIES (cache 1h):" + if (n_top > 0) { + line = " TOP: " tops[0] + for (i=1; i 0) { + line = " FRESH (7d): " fresh[0] + for (i=1; i 0) { + line = " PIPELINES: " pipes[0] + for (i=1; i"$CACHE_FILE" 2>/dev/null || exit 0 +fi + +# Emit cached content as additionalContext +if [ ! -s "$CACHE_FILE" ]; then + exit 0 +fi + +ctx=$(cat "$CACHE_FILE") +if command -v jq >/dev/null 2>&1; then + jq -n --arg ctx "$ctx" '{ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: $ctx + } + }' +else + # Fallback: print raw text (Claude Code prints stdout as context too) + printf '%s\n' "$ctx" +fi + +exit 0 diff --git a/.claude/scripts/hook_fn_match.sh b/.claude/scripts/hook_fn_match.sh new file mode 100755 index 00000000..c34b8ab7 --- /dev/null +++ b/.claude/scripts/hook_fn_match.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# PreToolUse hook: sugiere funciones del registry cuando un comando Bash +# inline probablemente reinventa una funcion existente (issue 0087). +# +# Llama a `./fn match ""` con timeout 200ms. Si encaja con alta +# confianza, imprime un a stderr para que Claude Code +# lo lea como recordatorio. NUNCA bloquea la tool — exit 0 siempre. + +set -euo pipefail + +# ---- Always exit 0, no matter what ---- +trap 'exit 0' ERR + +# ---- Resolve registry root (walks up from cwd) ---- +resolve_root() { + local d="${PWD}" + while [ "$d" != "/" ]; do + if [ -f "$d/registry.db" ]; then + printf '%s' "$d" + return 0 + fi + d=$(dirname "$d") + done + return 1 +} + +ROOT=$(resolve_root) || exit 0 +FN_BIN="$ROOT/fn" +[ -x "$FN_BIN" ] || exit 0 + +# ---- Read stdin JSON ---- +command -v jq >/dev/null 2>&1 || exit 0 +INPUT=$(cat) +[ -z "$INPUT" ] && exit 0 + +TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "") +[ "$TOOL_NAME" = "Bash" ] || exit 0 + +CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "") +[ -z "$CMD" ] && exit 0 + +# Single-line for matching against denylist patterns +CMD_FLAT=$(printf '%s' "$CMD" | tr '\n' ' ') + +# ---- Denylist (skip antes de llamar fn match para ahorrar el invoke) ---- + +# Comandos demasiado cortos -> trivial +CMD_LEN=${#CMD_FLAT} +[ "$CMD_LEN" -lt 20 ] && exit 0 + +# Trivial single-utility commands +case "$CMD_FLAT" in + "ls"|"ls "*|"cd"|"cd "*|"pwd"|"pwd "*|"cat"|"cat "*|"echo"|"echo "*) + exit 0 ;; + "grep"|"grep "*|"head"|"head "*|"tail"|"tail "*|"wc"|"wc "*) + exit 0 ;; + "mkdir"|"mkdir "*|"rm"|"rm "*|"mv"|"mv "*|"cp"|"cp "*) + exit 0 ;; + "git"|"git "*) + exit 0 ;; + "go"|"go "*) + # go build / go test corrientes — el agente ya los maneja + exit 0 ;; +esac + +# Comandos que ya usan el registry: ./fn ..., fn run ..., mcp__registry__* +if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])\./fn([[:space:]]|$)'; then + exit 0 +fi +if printf '%s' "$CMD_FLAT" | grep -qE '(^|[[:space:]])fn[[:space:]]+(run|search|show|code|uses|doctor|index|match|list|add|proposal|sync|ops|check)'; then + exit 0 +fi + +# Pure-cd (movement only, no logic) +if printf '%s' "$CMD_FLAT" | grep -qE '^[[:space:]]*cd[[:space:]]+[^&|;]+$'; then + exit 0 +fi + +# ---- Llamar fn match con timeout 200ms ---- +command -v timeout >/dev/null 2>&1 || exit 0 + +# Truncar el comando a algo razonable para fn match (evitar args huge) +CMD_TRUNC=$(printf '%s' "$CMD_FLAT" | head -c 500) + +MATCH_JSON=$(timeout 0.2 "$FN_BIN" match "$CMD_TRUNC" --format json --top 3 2>/dev/null) || exit 0 +[ -z "$MATCH_JSON" ] && exit 0 + +# ---- Parsear JSON ---- +HIGH_CONF=$(printf '%s' "$MATCH_JSON" | jq -r '.high_confidence // false' 2>/dev/null || echo "false") +TOP_ID=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].id // ""' 2>/dev/null || echo "") +TOP_SCORE=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].score // 0' 2>/dev/null || echo "0") +TOP_SIG=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].signature // ""' 2>/dev/null || echo "") +TOP_SNIP=$(printf '%s' "$MATCH_JSON" | jq -r '.top[0].snippet // ""' 2>/dev/null || echo "") + +[ -z "$TOP_ID" ] && exit 0 + +# Trigger condition: (high_confidence==true OR score>=0.85) AND score>=0.6 +# - high_confidence requires top1/top2 gap > 1.5 (set por fn match) +# - score>=0.85 cubre matches muy fuertes donde el gap es modesto +SCORE_HI=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.85) ? "1" : "0" }') +SCORE_MIN=$(awk -v s="$TOP_SCORE" 'BEGIN{ print (s+0 >= 0.6) ? "1" : "0" }') + +[ "$SCORE_MIN" = "1" ] || exit 0 +if [ "$HIGH_CONF" != "true" ] && [ "$SCORE_HI" != "1" ]; then + exit 0 +fi + +# Truncar snippet a 100 chars y limpiar saltos de linea +SNIP_SHORT=$(printf '%s' "$TOP_SNIP" | tr '\n' ' ' | head -c 100) + +# Formatear score con 2 decimales +SCORE_FMT=$(awk -v s="$TOP_SCORE" 'BEGIN{ printf "%.2f", s+0 }') + +# ---- Emitir a stderr ---- +cat >&2 <FUZZY-MATCH (issue 0087): your Bash command may already be a function. +USE: ./fn run $TOP_ID -> $TOP_SIG +SNIPPET: $SNIP_SHORT +Confidence: $SCORE_FMT. If you proceed inline, the violation will be logged. + +EOF + +exit 0 + +# Test manual: +# echo '{"tool_name":"Bash","tool_input":{"command":"taskkill.exe /IM registry_dashboard.exe /F"},"session_id":"test"}' \ +# | bash .claude/scripts/hook_fn_match.sh +# +# Casos silenciosos: +# echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"test"}' \ +# | bash .claude/scripts/hook_fn_match.sh +# echo '{"tool_name":"Bash","tool_input":{"command":"./fn run filter_slice_go_core 1 2 3"},"session_id":"test"}' \ +# | bash .claude/scripts/hook_fn_match.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e5de4fbf..121dd009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,44 @@ Para contexto detallado del trabajo diario ver `docs/diary/`. Para decisiones ar ## [Unreleased] +## 2026-05-14 + +### Added + +- **Issue 0086 — Monitor tab del `registry_dashboard`** (sub-repo `dataforge/registry_dashboard`). Pestaña `Monitor` primera y por defecto del TabBar, landing del bucle reactivo construir->ejecutar->recopilar->analizar->mejorar. + - 7 KPIs (Calls / MCP / Reg % / Errors / Violations / Copies / Versions) filtradas por ventana temporal (1h/24h/7d/30d/All). + - Sub-tab `Recent Executions` con columnas When/Function/Tool/ms/OK/Error. Columna Function muestra `$ ` en gris cuando `function_id` vacio, hover tooltip con comando completo. Checkbox `Only registry functions` filtra por `function_id != ''`. + - Sub-tab `Failed Functions` (5a) — subset filtrado a registry-functions fallidas, columnas When/Function/Tool/Error class/Error snippet, function_id en rojo. + - Live scatter `duracion (ms)` vs `time`: eje X auto-scroll a `now`, ventana configurable (1m/5m/15m/1h/6h) independiente del filtro de KPIs, eje Y dinamico `0..max(visible)+500ms`. Hora local (`UseLocalTime`). Series ok/error en verde/rojo. Hover sobre punto = tooltip Function/Tool/Duration/Error. + - Indicador `live`/`offline` con timestamp del ultimo evento WS. +- **WebSocket live stream sqlite_api -> registry_dashboard** (sub-repo `dataforge/sqlite_api`). Endpoint `GET /api/events/call_monitor`. Hub global con subscribers; ticker arranca solo con >=1 subscriber (cero overhead si nadie mira). Cliente recibe snapshot inicial (KPIs + 100 ultimas filas + watermark) y luego deltas `id > watermark`. Cliente puede mandar `{watermark: N}` para resumir tras reconexion. +- **WS client C++** hand-rolled RFC6455 en `ws_client.{h,cpp}` (~330 LOC) en el dashboard. Localhost-only (no TLS). Thread propio, reconnect exponencial 0.5s->8s, FIN/text/ping/pong/close handling, queue thread-safe drenada cada frame. +- **Migration 007 `command_snippet` en `calls`** (`projects/fn_monitoring/apps/call_monitor/migrations/007_calls_command_snippet.sql`). Aditiva, idempotente. Llena por hook `hook_call_monitor.sh` solo cuando `function_id == ''`. Redactado de `password=`/`token=`/`secret=`/`api_key=`/`bearer=`. Truncado 200 chars. +- **Issue 0087 — Capability Discovery Acceleration**. Modelo 5 capas + 7 piezas (ver `dev/issues/0087-*.md`). + - **`fn match`** (`cmd/fn/match.go`) — subcommand fuzzy-FTS5 que dado un comando devuelve top-N funciones del registry candidates. Latencia 6-7ms. Output JSON con `score` (normalizado top=1.0) + `raw_score` (absoluto pre-normalizacion) + `high_confidence` gate (`raw_score >= 4.0 AND top1.raw/top2.raw > 1.5`). + - **`fn doctor capabilities --emit-claude-md`** (`cmd/fn/doctor.go` + `functions/infra/emit_capabilities_md.go`) — emite bloque markdown con secciones TOP 20 (por `calls_total`), Fresh 7d, Pipelines top 5. Fallback si `call_monitor.operations.db` ausente. + - **`call_monitor sequences --detect [--propose]`** (`projects/fn_monitoring/apps/call_monitor/sequences.go` + `migrations/006_function_sequences.sql`). Detecta secuencias A->B(->C) en `calls` (same session, gap < 30s, occ >= 5, sess >= 2, success_rate >= 0.9) y abre proposals `new_pipeline` automaticamente. + - **Hook `PreToolUse` `hook_fn_match.sh`** — denylist + `fn match` con timeout 0.2s. Inyecta `FUZZY-MATCH: USE ./fn run ` cuando confidence alta. Latencia 113ms trigger / 32ms denylist. Registrado en `.claude/settings.local.json` (Bash matcher). + - **Hook `UserPromptSubmit` `hook_capabilities_inject.sh`** — cache 1h en `~/.cache/fn_registry/capabilities.txt`. Emite JSON `hookSpecificOutput.additionalContext` con linea compacta `CAPABILITIES: TOP / FRESH / PIPELINES`. Latencia cold 33ms / warm 18ms. + - **Timer systemd user** `call_monitor_sequences.timer` (OnCalendar 0/6h) + `.service` oneshot ejecutando `call_monitor sequences --detect --propose --report`. Versionado en `projects/fn_monitoring/apps/call_monitor/systemd/`. +- **3 funciones nuevas grupo `cpp-windows`** + pagina madre `docs/capabilities/cpp-windows.md`: + - `launch_cpp_app_windows_bash_infra` — `cmd.exe`/`PowerShell Start-Process` para lanzar exe en Windows desde WSL2. + - `is_cpp_app_running_windows_bash_infra` — `tasklist.exe /FI` con exit code 0/1 + stdout `RUNNING: PID=N MEM=K` o `NOT_RUNNING`. + - `redeploy_cpp_app_windows_bash_pipelines` — pipeline build? + deploy + launch + verify en 1 invocacion. Reemplaza ~6 commands manuales. +- **ADR 0004 `docs/adr/0004-telemetry-driven-capability-growth.md`** — formaliza el bucle telemetria -> proposal -> capability group -> discovery acceleration como motor de crecimiento del registry. +- **Regla `.claude/rules/function_growth_and_self_docs.md`** (entry #30 en `INDEX.md`) — contrato `.md` autosuficiente (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por promocion de composiciones, NO por inflado de funciones individuales. + +### Changed + +- **`.claude/CLAUDE.md` Norte ampliado** — 4o objetivo `PROMOVER COMPOSICIONES A PIPELINES` (el registry crece por composicion, no por inflado). Linea sobre auto-discovery zero-second-lookup. +- **`.claude/rules/registry_calls.md`** — clausula nueva: hooks e infraestructura de telemetria (`fn_match`, `fn doctor`, `call_monitor`) pueden leer `registry.db` directo con conexion read-only. NO sujeto a regla MCP-first (no son acciones del agente). +- **`/fn_claude` command** mejorado con objetivos del Monitor + interpretacion de `FUZZY-MATCH` hint + `CAPABILITIES` line + threshold semantica. + +### Fixed + +- **`launch_cpp_app_windows` quoting bug** — `cmd.exe /c "cd /d \"$dir\" && start ..."` rompia con paths Windows (el `\"` final se interpretaba como escape de comilla -> string sin cerrar -> "Windows cannot find \\"). Fix: reescribir a `powershell.exe -Command "Start-Process -FilePath ... -WorkingDirectory ..."` (single-quote PowerShell es literal, sin procesar `\` ni `$`). +- **`fn match high_confidence` siempre true** — debido a normalizacion `top=1.0`. Fix: añadir `raw_score` preservado pre-normalizacion + gate dual `raw_score >= 4.0 AND top1.raw/top2.raw > 1.5`. Threshold 4.0 tuneado contra 14 patrones del analysis `domain_coverage_gaps` (~93% precision). + ## 2026-05-07 ### Added diff --git a/bash/functions/infra/build_cpp_windows.md b/bash/functions/infra/build_cpp_windows.md index 0d300da8..774c00bf 100644 --- a/bash/functions/infra/build_cpp_windows.md +++ b/bash/functions/infra/build_cpp_windows.md @@ -7,7 +7,7 @@ version: "1.0.0" purity: impure signature: "build_cpp_windows(target?: string) -> void" description: "Cross-compila las funciones y apps C++ del registry para Windows usando mingw-w64" -tags: [cpp, build, cmake, windows, cross-compile, mingw, imgui] +tags: [cpp, build, cmake, windows, cross-compile, mingw, imgui, cpp-windows] uses_functions: [] uses_types: [] returns: [] diff --git a/bash/functions/infra/deploy_cpp_exe_to_windows.md b/bash/functions/infra/deploy_cpp_exe_to_windows.md index 575dc9c8..75a7b086 100644 --- a/bash/functions/infra/deploy_cpp_exe_to_windows.md +++ b/bash/functions/infra/deploy_cpp_exe_to_windows.md @@ -7,7 +7,7 @@ version: "1.0.0" purity: impure signature: "deploy_cpp_exe_to_windows(app_name: string, app_dir: string) -> void" description: "Copia el .exe de Windows (compilado por build_cpp_windows) y sus assets al escritorio de Windows /mnt/c/Users/lucas/Desktop/apps//. Mata el proceso si esta corriendo (taskkill.exe pre-autorizado), copia DLLs, sincroniza assets/ y enrichers/ con rsync, maneja runtime Python embebido si python_runtime: true en app.md, y copia extras gx-cli. Preserva siempre local_files/ (estado del usuario)." -tags: [cpp, deploy, windows, exe, dll, assets, rsync] +tags: [cpp, deploy, windows, exe, dll, assets, rsync, cpp-windows] uses_functions: [] uses_types: [] returns: [] diff --git a/bash/functions/infra/e2e_run_cpp_windows.md b/bash/functions/infra/e2e_run_cpp_windows.md index 77e7df16..0da45fb8 100644 --- a/bash/functions/infra/e2e_run_cpp_windows.md +++ b/bash/functions/infra/e2e_run_cpp_windows.md @@ -3,7 +3,7 @@ name: e2e_run_cpp_windows lang: bash domain: infra description: "Cross-compila una app C++ del registry para Windows con mingw-w64, deploy al Desktop\\apps de Windows (matando instancia previa con taskkill.exe), lanza el .exe nativamente desde WSL y devuelve stdout + exit code. Pensado para tests headless tipo altsnap_jitter_test." -tags: [windows, e2e, cross-compile, test, mingw, pendiente-usar] +tags: [windows, e2e, cross-compile, test, mingw, pendiente-usar, cpp-windows] purity: impure kind: function signature: "e2e_run_cpp_windows(target string, --no-build, --no-deploy) int" diff --git a/bash/functions/infra/is_cpp_app_running_windows.md b/bash/functions/infra/is_cpp_app_running_windows.md new file mode 100644 index 00000000..dcb6d885 --- /dev/null +++ b/bash/functions/infra/is_cpp_app_running_windows.md @@ -0,0 +1,70 @@ +--- +name: is_cpp_app_running_windows +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "is_cpp_app_running_windows(app_name: string) -> void" +description: "Comprueba si un ejecutable Windows esta corriendo via tasklist.exe desde WSL2. Exit 0 si el proceso existe, exit 1 si no." +tags: [cpp, windows, monitoring, wsl, tasklist, process, cpp-windows] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/is_cpp_app_running_windows.sh" +params: + - name: app_name + desc: "Nombre del binario sin extension .exe (ej. registry_dashboard). Se le añade .exe internamente para el filtro de tasklist." +output: "Exit 0 con stdout 'RUNNING: PID= MEM=' si el proceso existe. Exit 1 con stdout 'NOT_RUNNING: ' si no. Exit 1 con stderr si tasklist.exe no esta disponible." +--- + +## Ejemplo + +```bash +if is_cpp_app_running_windows "registry_dashboard"; then + echo "Ya esta corriendo" +else + launch_cpp_app_windows "registry_dashboard" +fi +``` + +Composicion tipica en pipelines de redeploy: + +```bash +# Matar antes de copiar si esta vivo +if is_cpp_app_running_windows "$app"; then + taskkill.exe /IM "${app}.exe" /F +fi +deploy_cpp_exe_to_windows "$app" "$app_dir" +launch_cpp_app_windows "$app" +``` + +## Comportamiento + +El exit code es la API principal; stdout es informacion adjunta parseable por el caller: + +| Situacion | Exit | Stdout | +|---|---|---| +| Proceso encontrado | 0 | `RUNNING: PID= MEM=K` | +| Proceso no encontrado | 1 | `NOT_RUNNING: ` | +| `tasklist.exe` no disponible | 1 | (stderr) `ERROR: tasklist.exe no encontrado` | +| `app_name` vacio | 1 | (stderr) `ERROR: uso: ...` | + +El formato `RUNNING: PID=N MEM=...K` esta pensado para ser parseado por `cut -d= -f2` si el caller necesita el PID. + +## Prerequisitos + +- WSL2 con acceso a herramientas Windows (`/mnt/c/Windows/System32/tasklist.exe`). +- El proceso debe haber sido lanzado via `launch_cpp_app_windows` o cualquier otro mecanismo que cree un proceso Windows con el nombre del `.exe`. + +## Notas + +Util para componer con `launch_cpp_app_windows` y `deploy_cpp_exe_to_windows` en pipelines de redeploy desde WSL2. `deploy_cpp_exe_to_windows` ya incluye un `taskkill.exe` interno antes de copiar, pero esta funcion permite tomar la decision de matar/no matar de forma explicita en el caller. + +Tasklist con `/NH /FO CSV` no emite header, por lo que la primera linea de salida con el nombre de imagen es directamente el primer proceso coincidente. Si hay multiples instancias del mismo `.exe`, se informa solo del primero (PID mas bajo en el listado del sistema). diff --git a/bash/functions/infra/is_cpp_app_running_windows.sh b/bash/functions/infra/is_cpp_app_running_windows.sh new file mode 100644 index 00000000..4ba7417c --- /dev/null +++ b/bash/functions/infra/is_cpp_app_running_windows.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# is_cpp_app_running_windows — Comprueba si un .exe de Windows esta corriendo +# via tasklist.exe desde WSL2. Exit 0 si vivo, exit 1 si no. + +is_cpp_app_running_windows() { + local app_name="${1:-}" + + if [ -z "$app_name" ]; then + echo "ERROR: uso: is_cpp_app_running_windows " >&2 + return 1 + fi + + # Verificar que tasklist.exe esta disponible + if ! command -v tasklist.exe >/dev/null 2>&1; then + echo "ERROR: tasklist.exe no encontrado (requiere WSL2 con acceso a Windows tools)" >&2 + return 1 + fi + + # /NH: sin header, /FO CSV: salida CSV, /FI: filtro por imagen + local output + output=$(tasklist.exe /FI "IMAGENAME eq ${app_name}.exe" /NH /FO CSV 2>/dev/null) + + # tasklist.exe devuelve "INFO: No tasks..." cuando no hay coincidencia. + # Con CSV+NH, cada proceso encontrado produce: "imagen.exe","pid","tipo","sesion","mem" + if echo "$output" | grep -qi "\"${app_name}.exe\""; then + # Extraer PID y memoria de la primera linea que coincida + local line + line=$(echo "$output" | grep -i "\"${app_name}.exe\"" | head -n1) + + # Campos CSV: "imagen","PID","tipo","sesion","MEM K" + local pid mem + pid=$(echo "$line" | cut -d',' -f2 | tr -d '"') + mem=$(echo "$line" | cut -d',' -f5 | tr -d '"' | tr -d ' ') + + echo "RUNNING: PID=${pid} MEM=${mem}" + return 0 + else + echo "NOT_RUNNING: ${app_name}" + return 1 + fi +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + is_cpp_app_running_windows "$@" +fi diff --git a/bash/functions/infra/launch_cpp_app_windows.md b/bash/functions/infra/launch_cpp_app_windows.md new file mode 100644 index 00000000..f8e3bc87 --- /dev/null +++ b/bash/functions/infra/launch_cpp_app_windows.md @@ -0,0 +1,70 @@ +--- +name: launch_cpp_app_windows +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "launch_cpp_app_windows(app_name: string, [desktop_dir: string]) -> void" +description: "Lanza un binario .exe en Windows desde WSL2. Asume que deploy_cpp_exe_to_windows ya copió el exe a Desktop/apps//. Usa cmd.exe /c start para desacoplar el proceso y retornar inmediatamente." +tags: [cpp, windows, launch, wsl, exe, cpp-windows] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/launch_cpp_app_windows.sh" +params: + - name: app_name + desc: "Nombre de la app (ej: registry_dashboard). Localiza Desktop/apps//.exe." + - name: desktop_dir + desc: "Override opcional del directorio escritorio Windows. Default: /mnt/c/Users/lucas/Desktop." +output: "Imprime 'OK: launched at ' en stdout si el exe existe y el comando se lanza. Errores fatales a stderr con exit 1." +--- + +## Ejemplo + +```bash +source bash/functions/infra/launch_cpp_app_windows.sh + +# Lanzar con default desktop dir +launch_cpp_app_windows "registry_dashboard" +# OK: registry_dashboard launched at 2026-05-14T10:32:01 + +# Override de desktop_dir (ej. otro usuario) +launch_cpp_app_windows "chart_demo" "/mnt/c/Users/otrouser/Desktop" +# OK: chart_demo launched at 2026-05-14T10:32:05 +``` + +## Comportamiento + +`cmd.exe /c start` es la clave: lanza el proceso en Windows y **retorna inmediatamente** sin esperar a que el exe termine. El proceso queda desacoplado del shell WSL2. Esta funcion **no verifica** que el exe arranco correctamente ni que sigue corriendo — esa responsabilidad recae en `is_cpp_app_running_windows` (funcion complementaria). + +El `cd /d` previo al `start` es esencial: los apps C++ del registry buscan sus assets, `local_files/` y DLLs relativos al directorio de trabajo. Sin el `cd`, Windows buscaria desde `C:\Windows\System32` y el exe no encontraria nada. + +## Prerequisitos + +- **WSL2**: la funcion usa `wslpath -w` y `cmd.exe`, ambos solo disponibles en WSL2. +- **`/mnt/c/` montado**: el exe debe ser accesible via la ruta `/mnt/c/...`. +- **Exe ya copiado**: `deploy_cpp_exe_to_windows` debe haberse ejecutado antes. Esta funcion no compila ni copia nada. + +## Notes + +Mitad complementaria de `deploy_cpp_exe_to_windows_bash_infra`. El flujo completo para actualizar y relanzar una app es: + +```bash +# 1. Compilar para Windows +build_cpp_windows "registry_dashboard" + +# 2. Copiar al escritorio (mata proceso si corre, copia DLLs+assets) +deploy_cpp_exe_to_windows "registry_dashboard" "/home/lucas/fn_registry/apps/registry_dashboard" + +# 3. Lanzar +launch_cpp_app_windows "registry_dashboard" +``` + +No se incluyen tests automatizados porque requieren entorno WSL2 con Windows activo y no son automatizables en CI. diff --git a/bash/functions/infra/launch_cpp_app_windows.sh b/bash/functions/infra/launch_cpp_app_windows.sh new file mode 100644 index 00000000..d2ace91d --- /dev/null +++ b/bash/functions/infra/launch_cpp_app_windows.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# launch_cpp_app_windows — Lanza un .exe en Windows desde WSL2 via cmd.exe /c start. +# Asume que el exe ya fue copiado por deploy_cpp_exe_to_windows al escritorio. + +launch_cpp_app_windows() { + local app="${1:-}" + local desktop_dir="${2:-/mnt/c/Users/lucas/Desktop}" + + if [ -z "$app" ]; then + echo "ERROR: uso: launch_cpp_app_windows [desktop_dir]" >&2 + return 1 + fi + + local exe_path="$desktop_dir/apps/$app/$app.exe" + + if [ ! -f "$exe_path" ]; then + echo "ERROR: exe no encontrado: $exe_path" >&2 + echo "Copia primero con: deploy_cpp_exe_to_windows $app " >&2 + return 1 + fi + + # Usamos PowerShell en vez de cmd.exe — los backslashes de paths Windows + # no rompen el escaping aqui (cmd.exe `\"` interpreta como escape de + # comilla y deja el string sin cerrar; PowerShell single-quote es literal). + local win_app_dir win_exe + win_app_dir=$(wslpath -w "$desktop_dir/apps/$app") + win_exe="$win_app_dir\\$app.exe" + + # Start-Process detacha (equivale a `start` de cmd) y respeta -WorkingDirectory. + # Las comillas simples en PowerShell son literales — no procesa \ ni $. + powershell.exe -NoProfile -Command \ + "Start-Process -FilePath '$win_exe' -WorkingDirectory '$win_app_dir'" \ + >/dev/null 2>&1 + + local ts + ts=$(date '+%Y-%m-%dT%H:%M:%S') + echo "OK: $app launched at $ts" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + launch_cpp_app_windows "$@" +fi diff --git a/bash/functions/pipelines/compile_cpp_app.md b/bash/functions/pipelines/compile_cpp_app.md index f381f3da..a9583ccb 100644 --- a/bash/functions/pipelines/compile_cpp_app.md +++ b/bash/functions/pipelines/compile_cpp_app.md @@ -7,7 +7,7 @@ version: "1.0.0" purity: impure signature: "compile_cpp_app(app_name?: string) -> void" description: "Pipeline que resuelve la app C++ desde el nombre o CWD, la cross-compila para Windows con mingw-w64, y despliega el .exe al escritorio de Windows. Composicion de resolve_cpp_app_dir + build_cpp_windows + deploy_cpp_exe_to_windows." -tags: [cpp, compile, windows, mingw, cross-compile, deploy, pipeline, pendiente-usar] +tags: [cpp, compile, windows, mingw, cross-compile, deploy, pipeline, pendiente-usar, cpp-windows] uses_functions: - resolve_cpp_app_dir_bash_infra - build_cpp_windows_bash_infra diff --git a/bash/functions/pipelines/redeploy_cpp_app_windows.md b/bash/functions/pipelines/redeploy_cpp_app_windows.md new file mode 100644 index 00000000..cdc2250b --- /dev/null +++ b/bash/functions/pipelines/redeploy_cpp_app_windows.md @@ -0,0 +1,75 @@ +--- +name: redeploy_cpp_app_windows +kind: pipeline +lang: bash +domain: pipelines +version: "1.0.0" +purity: impure +signature: "redeploy_cpp_app_windows(app_name: string, app_dir: string, [--build]) -> void" +description: "Pipeline orquestador para redeployar una app C++ en Windows desde WSL2 en un solo comando. Reemplaza la secuencia manual taskkill+copy+launch+verify." +tags: [cpp, windows, redeploy, pipeline, wsl, launcher, cpp-windows] +uses_functions: + - build_cpp_windows_bash_infra + - deploy_cpp_exe_to_windows_bash_infra + - launch_cpp_app_windows_bash_infra + - is_cpp_app_running_windows_bash_infra +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/pipelines/redeploy_cpp_app_windows.sh" +params: + - name: app_name + desc: "Nombre de la app C++ (ej: chart_demo, registry_dashboard). Se usa para localizar el .exe en cpp/build/windows/apps// y el destino Desktop/apps//." + - name: app_dir + desc: "Ruta absoluta al directorio fuente de la app (ej: /home/lucas/fn_registry/cpp/apps/chart_demo). Requerido para localizar enrichers/, runtime/ y app.md." + - name: "--build" + desc: "Flag opcional. Si presente, compila la app para Windows antes del deploy. Por defecto off (asume .exe ya compilado)." +output: "Imprime 'OK: redeployed (build=yes/no, PID=N)' en stdout. Exit 1 en cualquier paso fallido con mensaje de error indicando el paso." +--- + +## Ejemplo + +```bash +# Solo redeploy (asume build ya hecho) +redeploy_cpp_app_windows "registry_dashboard" "/home/lucas/fn_registry/projects/fn_monitoring/apps/registry_dashboard" + +# Con build previo +redeploy_cpp_app_windows "chart_demo" "/home/lucas/fn_registry/cpp/apps/chart_demo" --build +``` + +## Comportamiento + +1. **Parsear flag `--build`** (default off, opt-in). +2. **Si `--build`**: invocar `build_cpp_windows ` para compilar `cpp/build/windows/apps//.exe`. Si falla, exit 1 sin tocar el Desktop. +3. **Deploy**: invocar `deploy_cpp_exe_to_windows "" ""`. Esta función mata el proceso si está vivo (taskkill.exe pre-autorizado), copia exe + DLLs + assets + runtime + enrichers, y preserva `local_files/`. +4. **Launch**: invocar `launch_cpp_app_windows ""` para arrancar la app en Windows. +5. **Wait**: `sleep 1` — espera arranque corto. +6. **Verify**: invocar `is_cpp_app_running_windows ""`. Si NO está vivo → exit 1 con mensaje claro. +7. **Stdout final**: `OK: redeployed (build=yes/no, PID=N)`. + +## Argumentos + +- `app_name` — obligatorio. Nombre corto de la app. +- `app_dir` — obligatorio. Ruta absoluta al directorio fuente (donde vive `app.md`, `enrichers/`, `runtime/`). +- `--build` — opcional. Activa la compilación antes del deploy. + +## Errores + +Cada paso es punto de fallo independiente. En caso de error el pipeline imprime a stderr indicando qué paso falló y hace exit 1: + +- `ERROR [build]: build_cpp_windows falló` — fallo de compilación. +- `ERROR [deploy]: deploy_cpp_exe_to_windows falló` — fallo al copiar archivos. +- `ERROR [launch]: launch_cpp_app_windows falló` — la app no arrancó. +- `ERROR [verify]: no está corriendo` — la app arrancó pero murió antes de 1s. + +## Notas + +- Ahorra ~6 comandos manuales por redeploy (taskkill + cp + cd + start + sleep + tasklist). Motivación: issue 0086 — maximizar `Reg %` registrando esta secuencia como pipeline trazable. +- Cada `fn run redeploy_cpp_app_windows_bash_pipelines` queda registrado en `call_monitor/operations.db`, alimentando las métricas del bucle reactivo. +- `launch_cpp_app_windows_bash_infra` e `is_cpp_app_running_windows_bash_infra` son creadas por un fn-constructor paralelo. Si aún no existen en el registry al indexar, el indexer lo registra como advertencia pero el pipeline es válido structuralmente. +- Tag `launcher` incluido: este pipeline tiene sentido en el Pipeline Launcher TUI (`apps/pipeline_launcher`) — es un workflow interactivo lanzable con argumentos. diff --git a/bash/functions/pipelines/redeploy_cpp_app_windows.sh b/bash/functions/pipelines/redeploy_cpp_app_windows.sh new file mode 100644 index 00000000..f6986a75 --- /dev/null +++ b/bash/functions/pipelines/redeploy_cpp_app_windows.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# redeploy_cpp_app_windows — Pipeline orquestador: build (opcional) + deploy + launch + verify +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../infra/build_cpp_windows.sh" +source "$SCRIPT_DIR/../infra/deploy_cpp_exe_to_windows.sh" +source "$SCRIPT_DIR/../infra/launch_cpp_app_windows.sh" +source "$SCRIPT_DIR/../infra/is_cpp_app_running_windows.sh" + +redeploy_cpp_app_windows() { + local app_name="" + local app_dir="" + local do_build=0 + + # Parsear argumentos posicionales y flags + while [[ $# -gt 0 ]]; do + case "$1" in + --build) + do_build=1 + shift + ;; + -*) + echo "redeploy_cpp_app_windows: flag desconocido: $1" >&2 + return 1 + ;; + *) + if [[ -z "$app_name" ]]; then + app_name="$1" + elif [[ -z "$app_dir" ]]; then + app_dir="$1" + else + echo "redeploy_cpp_app_windows: argumento inesperado: $1" >&2 + return 1 + fi + shift + ;; + esac + done + + if [[ -z "$app_name" || -z "$app_dir" ]]; then + echo "redeploy_cpp_app_windows: uso: redeploy_cpp_app_windows [--build]" >&2 + return 1 + fi + + # Paso 1: build opcional + if [[ $do_build -eq 1 ]]; then + echo "[1/4] Building $app_name for Windows..." + if ! build_cpp_windows "$app_name"; then + echo "ERROR [build]: build_cpp_windows falló para '$app_name'" >&2 + return 1 + fi + echo "[1/4] Build OK" + else + echo "[1/4] Build skipped (--build no especificado)" + fi + + # Paso 2: deploy (taskkill + copy exe/DLLs/assets/runtime + preserva local_files) + echo "[2/4] Deploying $app_name to Windows Desktop..." + if ! deploy_cpp_exe_to_windows "$app_name" "$app_dir"; then + echo "ERROR [deploy]: deploy_cpp_exe_to_windows falló para '$app_name'" >&2 + return 1 + fi + echo "[2/4] Deploy OK" + + # Paso 3: lanzar la app + echo "[3/4] Launching $app_name..." + if ! launch_cpp_app_windows "$app_name"; then + echo "ERROR [launch]: launch_cpp_app_windows falló para '$app_name'" >&2 + return 1 + fi + echo "[3/4] Launch OK" + + # Paso 4: esperar arranque y verificar + sleep 1 + echo "[4/4] Verifying $app_name is running..." + local pid="" + if ! pid=$(is_cpp_app_running_windows "$app_name"); then + echo "ERROR [verify]: $app_name no está corriendo tras el lanzamiento. Revisa logs en Desktop/apps/$app_name/." >&2 + return 1 + fi + echo "[4/4] Running OK" + + local build_label="no" + [[ $do_build -eq 1 ]] && build_label="yes" + echo "OK: $app_name redeployed (build=$build_label, PID=$pid)" +} + +# Ejecutar si se llama directamente (fn run lo invoca como script) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + redeploy_cpp_app_windows "$@" +fi diff --git a/cmd/fn/doctor.go b/cmd/fn/doctor.go index 11d31f72..f2413ce0 100644 --- a/cmd/fn/doctor.go +++ b/cmd/fn/doctor.go @@ -12,11 +12,14 @@ import ( func cmdDoctor(args []string) { jsonOut := false + emitClaudeMd := false sub := "" for _, a := range args { switch a { case "--json": jsonOut = true + case "--emit-claude-md": + emitClaudeMd = true case "-h", "--help": doctorUsage() return @@ -51,7 +54,11 @@ func cmdDoctor(args []string) { case "copied-code": doctorCopiedCode(r, jsonOut) case "capabilities": - doctorCapabilities(r, jsonOut) + if emitClaudeMd { + doctorCapabilitiesEmitMd(r) + } else { + doctorCapabilities(r, jsonOut) + } default: fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub) doctorUsage() @@ -79,7 +86,8 @@ Subcommands: capabilities Drift entre docs/capabilities/INDEX.md, tags de funciones, y paginas .md (issue 0086) Flags: - --json Salida JSON (para scripting/agentes)`) + --json Salida JSON (para scripting/agentes) + --emit-claude-md (solo capabilities) Genera bloque markdown para CLAUDE.md`) } func doctorAll(root string, jsonOut bool) { @@ -432,6 +440,15 @@ func doctorCapabilities(root string, jsonOut bool) { fmt.Printf("\n%d/%d capability groups healthy.\n", len(audits)-bad, len(audits)) } +func doctorCapabilitiesEmitMd(root string) { + result, err := infra.EmitCapabilitiesMd(root) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + fmt.Print(infra.RenderCapabilitiesMd(result)) +} + func doctorCopiedCode(root string, jsonOut bool) { entries, err := infra.AuditCopiedCode(root) if err != nil { diff --git a/cmd/fn/main.go b/cmd/fn/main.go index b72ee19f..6a4a881f 100644 --- a/cmd/fn/main.go +++ b/cmd/fn/main.go @@ -49,6 +49,8 @@ func main() { cmdVault(os.Args[2:]) case "doctor": cmdDoctor(os.Args[2:]) + case "match": + cmdMatch(os.Args[2:]) case "help", "-h", "--help": printUsage() default: @@ -77,7 +79,9 @@ Usage: fn sync [status|locations] Sincroniza con servidor central fn vault Gestiona y busca en data vaults fn doctor [artefacts|services|sync|uses-functions|unused] [--json] - Diagnostico read-only del registry`) + Diagnostico read-only del registry + fn match [--top N] [--format json|text] [--min-score F] "" + Fuzzy match entre comando shell y funciones del registry`) } func root() string { diff --git a/cmd/fn/match.go b/cmd/fn/match.go new file mode 100644 index 00000000..1c99fcda --- /dev/null +++ b/cmd/fn/match.go @@ -0,0 +1,553 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + _ "github.com/mattn/go-sqlite3" +) + +// matchResult holds one candidate function match. +type matchResult struct { + ID string `json:"id"` + Score float64 `json:"score"` // normalized (top=1.0) + RawScore float64 `json:"raw_score"` // absolute, pre-normalization. Use for confidence gates. + Signature string `json:"signature"` + Snippet string `json:"snippet"` + Lang string `json:"-"` + Name string `json:"-"` + Tags string `json:"-"` + HighConfidence bool `json:"-"` // filled after ranking +} + +// matchOutput is the JSON envelope returned by fn match. +type matchOutput struct { + Query string `json:"query"` + Top []matchResult `json:"top"` + HighConfidence bool `json:"high_confidence"` +} + +// fts5Row is a raw row from the FTS query. +type fts5Row struct { + id string + name string + lang string + signature string + description string + tags string + rank float64 +} + +// --- tokenizer --------------------------------------------------------- + +var ( + reNonAlnum = regexp.MustCompile(`[^a-zA-Z0-9]+`) + reFlag = regexp.MustCompile(`^-{1,2}[a-zA-Z]`) + reAbsPath = regexp.MustCompile(`^(/|[A-Za-z]:\\|\\\\)`) + rePureNumber = regexp.MustCompile(`^\d+$`) +) + +// domainStopwords are tokens so generic in this codebase that they add noise +// rather than signal to the matcher (they match hundreds of functions equally). +var domainStopwords = map[string]bool{ + "registry": true, "function": true, "functions": true, + "app": true, "apps": true, "file": true, "files": true, + "get": true, "set": true, "run": true, "list": true, "add": true, + "new": true, "all": true, "the": true, "and": true, "for": true, + "use": true, "fmt": true, "log": true, "err": true, "nil": true, + "true": true, "false": true, "var": true, "val": true, "str": true, + "tmp": true, "out": true, "src": true, "dst": true, "opt": true, + "usr": true, "etc": true, "bin": true, "lib": true, "mnt": true, + "home": true, "root": true, "host": true, "user": true, "name": true, + "path": true, "type": true, "data": true, "info": true, "init": true, + "main": true, "test": true, "util": true, "base": true, "core": true, + "api": true, "url": true, "uri": true, "http": true, "html": true, + "json": true, "yaml": true, "toml": true, "conf": true, "config": true, + "dir": true, "map": true, "key": true, "obj": true, + "ctx": true, "pkg": true, "mod": true, "cmd": true, "cli": true, + "help": true, "read": true, "open": true, "close": true, "stop": true, + "start": true, "end": true, "begin": true, "done": true, "make": true, + "build": true, "check": true, "scan": true, "load": true, "save": true, + "send": true, "recv": true, "show": true, "print": true, "write": true, + "create": true, "update": true, "delete": true, "remove": true, + "desktop": true, "lucas": true, "windows": true, "linux": true, +} + +// tokenize splits a shell command into significant lowercase tokens. +// It discards flags, absolute paths (keeping basenames), pure numbers, +// and short tokens (< 3 chars). +func tokenize(cmd string) []string { + // Replace common shell operators with spaces so they act as separators + cmd = strings.NewReplacer("|", " ", ";", " ", "&&", " ", "||", " ", + "(", " ", ")", " ", "{", " ", "}", " ").Replace(cmd) + + parts := strings.Fields(cmd) + seen := map[string]bool{} + var tokens []string + + for _, p := range parts { + // Skip flags like -v, --port, /F, /IM + if reFlag.MatchString(p) || (len(p) > 1 && p[0] == '/') { + continue + } + // Handle paths: keep only basename without extension + if reAbsPath.MatchString(p) || strings.ContainsAny(p, "/\\") { + p = filepath.Base(p) + ext := filepath.Ext(p) + if ext != "" { + p = strings.TrimSuffix(p, ext) + // also add ext without dot + extTok := strings.ToLower(strings.TrimPrefix(ext, ".")) + if len(extTok) >= 3 && !seen[extTok] { + seen[extTok] = true + tokens = append(tokens, extTok) + } + } + } + // Split remaining by non-alphanumeric chars + subparts := reNonAlnum.Split(p, -1) + for _, sp := range subparts { + tok := strings.ToLower(sp) + if len(tok) < 3 { + continue + } + if rePureNumber.MatchString(tok) { + continue + } + if seen[tok] { + continue + } + if domainStopwords[tok] { + continue + } + seen[tok] = true + tokens = append(tokens, tok) + } + } + return tokens +} + +// buildFTSQuery constructs a safe FTS5 OR query from tokens. +// Tokens with special FTS5 characters are wrapped in double quotes. +func buildFTSQuery(tokens []string) string { + if len(tokens) == 0 { + return "" + } + var parts []string + specialChars := `"'()^*:-.` + for _, tok := range tokens { + needsQuoting := false + for _, c := range tok { + if strings.ContainsRune(specialChars, c) { + needsQuoting = true + break + } + } + if needsQuoting { + // escape inner double quotes + escaped := strings.ReplaceAll(tok, `"`, `""`) + parts = append(parts, `"`+escaped+`"`) + } else { + parts = append(parts, tok) + } + } + return strings.Join(parts, " OR ") +} + +// --- language penalty heuristics --------------------------------------- + +// pythonMarkers are tokens that strongly suggest Python code. +var pythonMarkers = map[string]bool{ + "def": true, "import": true, "class": true, "elif": true, + "self": true, "lambda": true, "yield": true, "async": true, + "await": true, "with": true, +} + +// bashMarkers are tokens that strongly suggest Bash code. +var bashMarkers = map[string]bool{ + "chmod": true, "chown": true, "grep": true, "awk": true, + "sed": true, "curl": true, "wget": true, "ssh": true, + "rsync": true, "systemctl": true, "apt": true, "yum": true, + "taskkill": true, "cmd": true, "powershell": true, + "exe": true, "bat": true, +} + +func hasPythonMarkers(tokens []string) bool { + for _, t := range tokens { + if pythonMarkers[t] { + return true + } + } + return false +} + +func hasBashMarkers(tokens []string) bool { + for _, t := range tokens { + if bashMarkers[t] { + return true + } + } + return false +} + +// --- scoring ----------------------------------------------------------- + +// scoreHit computes a composite score for a single FTS5 hit. +// bm25 from SQLite is negative (more negative = better match). +// Scoring uses an additive boost model: each token that matches a field +// contributes a flat bonus (name=3.0, tags=2.0, signature=1.5). The total +// bonus is added to the base BM25 score, not multiplied per-token. This +// prevents runaway clamping when many tokens all match different functions +// equally (dashboard + registry + exe → all score 1.0 with the old model). +func scoreHit(row fts5Row, tokens []string, hasPython, hasBash bool) float64 { + // Base score from BM25 rank (negative -> positive, bounded [0,1]) + base := 1.0 / (1.0 + math.Abs(row.rank)) + + nameLower := strings.ToLower(row.name) + tagsLower := strings.ToLower(row.tags) + sigLower := strings.ToLower(row.signature) + descLower := strings.ToLower(row.description) + + var boost float64 + for _, tok := range tokens { + // Use best-field bonus per token (additive across tokens, not multiplicative) + tokBoost := 0.0 + if strings.Contains(nameLower, tok) && tokBoost < 3.0 { + tokBoost = 3.0 + } + if strings.Contains(tagsLower, tok) && tokBoost < 2.0 { + tokBoost = 2.0 + } + if strings.Contains(sigLower, tok) && tokBoost < 1.5 { + tokBoost = 1.5 + } + if strings.Contains(descLower, tok) && tokBoost < 1.0 { + tokBoost = 1.0 + } + boost += tokBoost + } + + // Language penalties (applied to total, not per-token) + penalty := 1.0 + langLower := strings.ToLower(row.lang) + if hasPython && langLower == "bash" { + penalty = 0.5 + } + if hasBash && langLower == "py" { + penalty = 0.5 + } + + // No clamping — scores differentiate via normalisation in the caller + return (base + boost) * penalty +} + +// snippet returns the first ~120 chars of description, trimmed cleanly. +func snippet(description string, maxLen int) string { + description = strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\t' { + return ' ' + } + return r + }, description) + description = strings.TrimSpace(description) + if len(description) <= maxLen { + return description + } + // Cut at last space before maxLen + cut := description[:maxLen] + if idx := strings.LastIndex(cut, " "); idx > maxLen/2 { + cut = cut[:idx] + } + return cut + "..." +} + +// --- FTS5 query -------------------------------------------------------- + +// ftsOnlyQuery returns id + rank from the FTS virtual table only. +// bm25() must be used without JOIN — it only works in direct FTS queries. +const ftsOnlyQuery = ` +SELECT id, bm25(functions_fts) AS rank +FROM functions_fts +WHERE functions_fts MATCH ? +ORDER BY rank +LIMIT 50 +` + +// fnDetailQuery fetches metadata for a list of IDs. +const fnDetailQuery = ` +SELECT id, name, lang, signature, description, COALESCE(tags, '[]') +FROM functions +WHERE id IN (%s) +` + +func runMatch(dbPath string, query string, topN int, minScore float64) ([]matchResult, error) { + tokens := tokenize(query) + if len(tokens) == 0 { + return nil, fmt.Errorf("no significant tokens extracted from: %q", query) + } + + ftsQ := buildFTSQuery(tokens) + if ftsQ == "" { + return nil, fmt.Errorf("could not build FTS query") + } + + // Open normally (not strict read-only) so WAL frames are visible. + // bm25() with mode=ro fails with "missing row from content table" when + // the WAL has not been checkpointed — the FTS index references rows that + // aren't in the main db file yet. We never write anything here. + conn, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("opening db: %w", err) + } + defer conn.Close() + + // Step 1: FTS-only query to get ids + bm25 ranks (no JOIN) + ftsRows, err := conn.Query(ftsOnlyQuery, ftsQ) + if err != nil { + return nil, fmt.Errorf("fts query: %w", err) + } + type idRank struct { + id string + rank float64 + } + var ranked []idRank + for ftsRows.Next() { + var r idRank + if err := ftsRows.Scan(&r.id, &r.rank); err != nil { + continue + } + ranked = append(ranked, r) + } + ftsRows.Close() + + if len(ranked) == 0 { + return nil, nil + } + + // Step 2: fetch metadata for those IDs with a regular SELECT + rankMap := make(map[string]float64, len(ranked)) + ids := make([]string, 0, len(ranked)) + placeholders := make([]string, 0, len(ranked)) + args := make([]any, 0, len(ranked)) + for _, r := range ranked { + rankMap[r.id] = r.rank + ids = append(ids, r.id) + placeholders = append(placeholders, "?") + args = append(args, r.id) + } + + detailSQL := fmt.Sprintf(fnDetailQuery, strings.Join(placeholders, ",")) + detailRows, err := conn.Query(detailSQL, args...) + if err != nil { + return nil, fmt.Errorf("detail query: %w", err) + } + defer detailRows.Close() + + hasPython := hasPythonMarkers(tokens) + hasBash := hasBashMarkers(tokens) + + var results []matchResult + for detailRows.Next() { + var r fts5Row + if err := detailRows.Scan(&r.id, &r.name, &r.lang, &r.signature, &r.description, &r.tags); err != nil { + continue + } + r.rank = rankMap[r.id] + score := scoreHit(r, tokens, hasPython, hasBash) + results = append(results, matchResult{ + ID: r.id, + Score: score, // rounded after normalisation below + Signature: r.signature, + Snippet: snippet(r.description, 120), + Lang: r.lang, + Name: r.name, + Tags: r.tags, + }) + } + + // Sort by score descending + sort.Slice(results, func(i, j int) bool { + return results[i].Score > results[j].Score + }) + + // Preserva raw_score (absoluto) ANTES de normalizar — sirve para gates + // de confidence absoluto. La normalizacion estetica enmascara queries + // debiles donde el top hit es solo el "menos malo" pero realmente no + // matchea — sin raw, high_confidence sobre normalized siempre dispara. + for i := range results { + results[i].RawScore = results[i].Score + } + // Normalise scores so the top result is 1.0 and the rest are relative. + // This makes the output stable and meaningful regardless of token count. + if len(results) > 0 && results[0].Score > 0 { + maxScore := results[0].Score + for i := range results { + results[i].Score = math.Round((results[i].Score/maxScore)*1000) / 1000 + } + } + + // Filter by min score + var filtered []matchResult + for _, r := range results { + if r.Score >= minScore { + filtered = append(filtered, r) + } + } + + // Limit to topN + if len(filtered) > topN { + filtered = filtered[:topN] + } + + return filtered, nil +} + +// --- command ----------------------------------------------------------- + +func cmdMatch(args []string) { + topN := 3 + format := "json" + minScore := 0.3 + var queryArg string + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--top", "-n": + i++ + if i < len(args) { + if n, err := strconv.Atoi(args[i]); err == nil && n > 0 { + topN = n + } + } + case "--format", "-f": + i++ + if i < len(args) { + format = args[i] + } + case "--min-score": + i++ + if i < len(args) { + if f, err := strconv.ParseFloat(args[i], 64); err == nil { + minScore = f + } + } + case "--help", "-h": + fmt.Println(`fn match — fuzzy matcher between a shell command and registry functions + +Usage: + fn match [--top N] [--format json|text] [--min-score F] "" + echo "" | fn match [--top N] [--format json|text] [--min-score F] + +Flags: + --top N Return top N results (default: 3) + --format Output format: json (default) or text + --min-score F Minimum score threshold 0..1 (default: 0.3) + +Example: + fn match "taskkill.exe /IM registry_dashboard.exe /F" + fn match --top 5 --format text "curl -sf https://api.example.com/health" + echo "rsync -avz --exclude .git src/ user@host:/opt/app" | fn match`) + return + default: + if !strings.HasPrefix(args[i], "-") { + queryArg = args[i] + } + } + } + + // Try stdin if no positional arg + if queryArg == "" { + stat, err := os.Stdin.Stat() + if err == nil && (stat.Mode()&os.ModeCharDevice) == 0 { + var sb strings.Builder + buf := make([]byte, 4096) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + sb.Write(buf[:n]) + } + if err != nil { + break + } + } + queryArg = strings.TrimSpace(sb.String()) + } + } + + if queryArg == "" { + fmt.Fprintln(os.Stderr, "fn match: no command provided. Use --help for usage.") + os.Exit(1) + } + + dbPath := filepath.Join(root(), dbName) + hits, err := runMatch(dbPath, queryArg, topN, minScore) + if err != nil { + fmt.Fprintf(os.Stderr, "fn match: %v\n", err) + os.Exit(1) + } + + // Compute high_confidence flag. Doble gate: + // 1. RAW score >= 3.0 — al menos un token con match fuerte de campo + // (name=3.0 / tags=2.0 / signature=1.5 / description=1.0). Sin esto, + // la normalizacion devolveria 1.0 incluso para queries que no + // matchean nada bien (ej. "kelly criterion" -> graph_renderer score + // raw < 1.0 pero normalized = 1.0). + // 2. Gap top1/top2 > 1.5 (en raw, no normalized) — el top destaca + // sobre el siguiente, no es un cluster de matches mediocres. + const minRawForHighConf = 4.0 + highConf := false + if len(hits) >= 1 && hits[0].RawScore >= minRawForHighConf { + if len(hits) >= 2 && hits[1].RawScore > 0 { + highConf = hits[0].RawScore/hits[1].RawScore > 1.5 + } else { + highConf = true // solo un hit con raw alta + } + } + + switch format { + case "text": + printMatchText(queryArg, hits, highConf) + default: + printMatchJSON(queryArg, hits, highConf) + } +} + +func printMatchJSON(query string, hits []matchResult, highConf bool) { + out := matchOutput{ + Query: query, + Top: hits, + HighConfidence: highConf, + } + if out.Top == nil { + out.Top = []matchResult{} + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(out) +} + +func printMatchText(query string, hits []matchResult, highConf bool) { + conf := "" + if highConf { + conf = " [HIGH CONFIDENCE]" + } + fmt.Printf("TOP MATCHES for: %s%s\n", query, conf) + if len(hits) == 0 { + fmt.Println(" (no matches above threshold)") + return + } + for _, h := range hits { + fmt.Printf(" [%.3f] %s\n", h.Score, h.ID) + fmt.Printf(" %s\n", h.Signature) + fmt.Printf(" %s\n", h.Snippet) + } +} diff --git a/dev/issues/0087-capability-discovery-acceleration.md b/dev/issues/0087-capability-discovery-acceleration.md new file mode 100644 index 00000000..ec6fe416 --- /dev/null +++ b/dev/issues/0087-capability-discovery-acceleration.md @@ -0,0 +1,101 @@ +# 0087 — Capability Discovery Acceleration + +**Status:** open +**Created:** 2026-05-14 +**Related:** 0085 (telemetry), 0086 (delegation + capability groups) + +## Problema + +Crear funciones para reutilizar = bueno largo plazo, pero introduce un **coste de descubrimiento** cada sesion: Claude tiene que FTS-buscar antes de usar. Si la busqueda falla o es mediocre, Claude reinventa inline ("violation: reinvent_inline"). En la sesion del 2026-05-13/14 Claude reinvento taskkill+cp+start 5 veces seguidas pese a que `deploy_cpp_exe_to_windows` ya existia. + +Objetivo: que **conocer las capacidades sea gratis o casi gratis** para Claude. + +## Modelo de capas + +Cinco capas escalonadas por coste de lookup. Cada una cubre un slice de casos: + +| Capa | Coste lookup | Cobertura | Mecanismo | +|---|---|---|---| +| 1 | 0 (contexto base) | Top 20 funciones mas usadas + funciones creadas ultimos 7d | Bloque auto-generado en `CLAUDE.md` | +| 2 | 0 (cada turno) | Funciones recien creadas + violations recientes | Hook `UserPromptSubmit` injecta linea `FRESH:` / `TOP:` | +| 3 | 0 (mid-flight) | Patrones inline que matchean funcion existente | Hook `PreToolUse` con fuzzy FTS5 | +| 4 | 1 read on demand | Cualquier dominio especifico | `docs/capabilities/.md` cargado solo si tarea matchea | +| 5 | 0 (persistente) | Insights cross-session | `MEMORY.md` linea por capacidad clave | + +## Piezas (7) + +| # | Pieza | Lang/Tipo | Aprox LOC | Tanda | +|---|---|---|---|---| +| 1 | `fn doctor capabilities --emit-claude-md` — top 20 + fresh 7d | Go subcommand | 80 | A | +| 2 | Hook `UserPromptSubmit` → linea `FRESH:` + `TOP:` | bash | 40 | B | +| 3 | `fn_match` binario fuzzy FTS5 sobre functions (input: command, output: top-N matches) | Go binary | 120 | A | +| 4 | Hook `PreToolUse` → consume `fn_match`, inyecta `USE:` hint si confidence alta | bash | 60 | B | +| 5 | `ids_naming.md` endurecer + validator en `fn_create_function` | Go + rules | 50 | A | +| 6 | `/fn_claude` post-creation auto-append `[[fn_id]] — purpose` a `MEMORY.md` | bash skill | 30 | C | +| 7 | Excepcion explicita en `registry_calls.md`: hooks pueden leer `registry.db` directo | rules | — | A | + +### Fuzzy matching (pieza 3 + 4) + +`fn_match`: +- **Input:** command string + optional tool context (Bash / Edit / heredoc). +- **Pipeline:** + 1. Tokenizar comando: split en palabras, ignora flags (`-v`, `/F`), conserva tokens significativos. + 2. Query FTS5 sobre `functions_fts MATCH 'tok1 OR tok2 OR ...'` ordenado por `bm25()`. + 3. Re-score con weights: `name` match peso x3, `tags` peso x2, `description` peso x1. + 4. Threshold dinamico: `top.score / second.score > 1.5` → confidence alta. +- **Output JSON:** `[{id, score, signature, snippet}, ...]` top 3. +- **Latencia objetivo:** < 50ms (hook PreToolUse no puede bloquear). + +Hook `PreToolUse`: +- Captura tool + payload. +- Llama `fn_match` con timeout 200ms (no bloquea si tarda). +- Si confidence alta → inyecta `FUZZY-MATCH: USE \`./fn run \` instead. Signature: .` en stderr (Claude lo lee). +- Si confidence baja → silencio. + +### Excepcion hooks (pieza 7) + +`.claude/rules/registry_calls.md` actualmente dice "NUNCA sqlite3 registry.db directo" pero esa regla apunta al AGENTE. Los hooks son lectores externos del proceso de Claude — necesitan acceso directo y rapido a FTS5 sin pasar por MCP (que requiere tool invocation por Claude). Anadir clausula: + +> **Hooks (PreToolUse/PostToolUse/UserPromptSubmit) pueden leer `registry.db` directo via `sqlite3` o binario Go con read-only conn.** No estan sujetos a la regla MCP-first porque no son acciones del agente, son inspeccion automatizada del entorno. Excepcion: SOLO lectura. NUNCA escritura desde hooks. + +## Plan de tandas (paralelizable) + +### Tanda A — cero dependencias, lanzar en paralelo + +- Pieza 1: `fn doctor capabilities --emit-claude-md` (Go). +- Pieza 3: `fn_match` binario (Go). +- Pieza 5: `ids_naming` validator + endurecer (Go + rules). +- Pieza 7: clausula en `registry_calls.md` (rules, instant). + +### Tanda B — depende de tanda A (consume binarios) + +- Pieza 2: hook `UserPromptSubmit` consume `fn doctor capabilities` output. +- Pieza 4: hook `PreToolUse` consume `fn_match`. + +### Tanda C — refuerzos + +- Pieza 6: extension de `/fn_claude` skill. + +## Acceptance criteria + +- [ ] `fn doctor capabilities --emit-claude-md` imprime bloque markdown valido pegable en CLAUDE.md. +- [ ] Hook `UserPromptSubmit` añade linea `FRESH: , , ...` cada turno (max 5 funciones de los ultimos 7d). +- [ ] `fn_match "taskkill registry_dashboard.exe"` devuelve `deploy_cpp_exe_to_windows_bash_infra` con score > threshold en < 50ms. +- [ ] Hook `PreToolUse` con `fn_match` muestra hint `USE: ...` en al menos 3 patrones reproducibles (taskkill, cp Desktop, cmd.exe start). +- [ ] Validator de `fn_create_function` rechaza nombres no predictibles (sin verbo o sin dominio). +- [ ] `/fn_claude` tras crear N funciones, MEMORY.md tiene N nuevas lineas `[[id]] — purpose`. +- [ ] Reglas: `registry_calls.md` documenta la excepcion explicita para hooks. + +## Metricas de exito + +Antes / despues, ventana 7d: +- `Reg %` (% calls con function_id != '') — esperado +10pp. +- `violations` count — esperado -50%. +- Tiempo medio entre "patron inline" y "uso de funcion correspondiente" — esperado dia 1 → mismo turno. + +Visibles en Monitor tab del `registry_dashboard`. + +## Notas + +- Capa 3 (fuzzy interceptor) es la mas potente pero requiere **benchmark** de latencia antes de habilitar en cada Bash. Si supera 100ms, degradar a "solo en comandos > N chars" o "solo al final del turno". +- No introducir mas reglas: las que hay (`registry_first.md`, `delegation.md`, `registry_calls.md`) son suficientes. Esto es **infraestructura para que se cumplan**. diff --git a/docs/adr/0004-telemetry-driven-capability-growth.md b/docs/adr/0004-telemetry-driven-capability-growth.md new file mode 100644 index 00000000..3c24c3ee --- /dev/null +++ b/docs/adr/0004-telemetry-driven-capability-growth.md @@ -0,0 +1,83 @@ +# ADR 0004 — Telemetria de ejecuciones de Claude como motor de crecimiento del registry + +- **Fecha:** 2026-05-14 +- **Estado:** accepted +- **Relacionados:** issue 0085 (telemetry + call_monitor), issue 0086 (delegation + capability groups), issue 0087 (capability discovery acceleration) + +## Contexto + +Hasta 0085, el registry crecia "manualmente": el humano (o Claude bajo supervision) detectaba patrones repetidos, redactaba proposals y un agente los implementaba. Costes observados en sesiones reales: + +- Claude reinventaba inline logica que ya existia como funcion. Caso reciente (2026-05-13/14): `taskkill + cp Desktop + cmd /c start` ejecutado 5 veces seguidas pese a que `deploy_cpp_exe_to_windows_bash_infra` ya cubria el caso. +- Funciones recien creadas quedaban huerfanas (`calls_90d=0`) porque Claude no las encontraba via FTS5 con queries genericas. +- Tres pasos del bucle reactivo (`MEJORAR → APROBAR → CONSTRUIR`) requerian humano en el loop incluso para gaps obvios. + +Sin medir lo que el agente realmente hace, las reglas (`registry_first.md`, `delegation.md`) son aspiracion no auditable. **No se puede mejorar lo que no se mide.** + +## Decision + +Tratar las **ejecuciones de Claude como senal de primera clase** del registry. Tres componentes encadenados: + +1. **Capturar todo** (issue 0085) — Hook `PostToolUse` parsea cada Bash + cada `mcp__registry__*` y persiste en `projects/fn_monitoring/apps/call_monitor/operations.db`. Tablas: `calls`, `code_writes`, `test_runs`, `e2e_runs_fn`, `violations`, `patterns`, `sessions`. Vista `function_stats` agrega por `function_id`. + +2. **Agrupar por capability** (issue 0086) — Tags planos (`notebook`, `metabase`, `deploy`, `cpp-windows`, ...) sobre funciones afines + pagina madre `docs/capabilities/.md` con lista, ejemplo canonico y fronteras. Cargar un grupo cuesta **1 read**, no N busquedas FTS5. + +3. **Acelerar descubrimiento** (issue 0087) — 5 capas escalonadas por coste de lookup: + - Top-20 + fresh-7d en bloque autogenerado de CLAUDE.md (coste 0, contexto base). + - Hook `UserPromptSubmit` injecta `FRESH:` / `TOP:` cada turno (coste 0). + - Hook `PreToolUse` con `fn_match` fuzzy FTS5 sugiere `USE: ./fn run ` mid-flight (coste <50ms). + - `docs/capabilities/.md` cargado solo si tarea matchea dominio (coste 1 read). + - `MEMORY.md` con insights cross-session (coste 0, persistente). + +Las 3 metricas norte (en Monitor tab del `registry_dashboard`): + +- `Reg %` — fraccion de calls con `function_id != ''`. Sube cuando Claude usa registry en vez de heredoc. +- `MCP ratio` — fraccion de `tool_used IN (mcp_*, fn_run)`. Mide adopcion canonica. +- `violations_24h` — antipatrones detectados. Baja con cada vuelta del bucle. + +Cualquier decision tecnica que choque con estas metricas esta mal priorizada. + +## Alternativas consideradas + +- **Reglas mas estrictas sin telemetria.** Descartado: ya teniamos `registry_first.md` y se ignoraba sin que nada lo detectase. Reglas sin metricas son aspiracion. +- **Auto-merge de proposals desde el bucle reactivo.** Descartado: el agente decide *que* construir, pero el humano sigue revisando antes de escribir codigo permanente. Limite duro en `/fn_claude`: max 5 funciones por invocacion, no push automatico. +- **MCP-only sin hooks de telemetria.** Descartado: heredocs y `sqlite3` directo seguirian invisibles. El hook `PostToolUse` es la red de captura unica que cubre todos los canales (MCP, fn_run, heredoc, Edit, Write). +- **Indice estatico (lista plana de IDs) en lugar de capability groups.** Descartado: una lista de 800 funciones no cabe en contexto. Grupos con pagina madre densa (~80 lineas) cubren 80% del trabajo con coste de lectura asumible. + +## Consecuencias + +### Nuevo (visible en este repo) + +- Carpeta `docs/capabilities/` con pagina madre por grupo. Indice en `docs/capabilities/INDEX.md`. Auditado por `fn doctor capabilities`. +- Hook `UserPromptSubmit` injecta cada turno: `REGISTRY-FIRST` + `CAPABILITY-GROWTH: created_this_session=X used=Y orphan=Z`. +- Hook `PostToolUse` (en `.claude/settings.local.json`) escribe a `call_monitor.operations.db`. Solo `args_hash`, NUNCA valores de argumentos (privacidad). +- Slash command `/fn_claude` — auto-auditoria: detecta gaps en la sesion, lanza `fn-constructor` en paralelo, valida que la proxima sesion usara la nueva funcion. +- Subagente `fn-constructor` mandatorio cuando se vaya a escribir >=5 lineas reutilizables inline. + +### Reglas nuevas / endurecidas + +- `.claude/rules/delegation.md` — STOP + spawn `fn-constructor` mismo turno, paralelo cuando >1 funcion independiente, tag de grupo obligatorio. +- `.claude/rules/capability_groups.md` — convencion de tag plano + pagina madre + auditoria via `fn doctor capabilities`. +- `.claude/rules/registry_calls.md` — 3 patrones canonicos (inspect / run / compose), antipatrones, excepciones para `sqlite3` directo. + +### Boundary explicito + +La telemetria cubre al **agente** y a **invocaciones canonicas**. Quedan fuera: + +- Funcion Go/C++ llamada internamente por app ya compilada. +- Service de produccion recibiendo HTTP sin pasar por `fn run`. +- Sub-agente (`Agent` tool) sin telemetria heredada (mitigado por env var `FN_TELEMETRY=1` propagada). + +Compensar runtime invisible con `e2e_checks` (issue 0068) y `fn doctor uses-functions` (audit estatico). + +### Riesgos asumidos + +- **Latencia de `fn_match` (capa 3).** Objetivo <50ms. Si supera 100ms, degradar a "solo en comandos largos" o "al final del turno". +- **Falsos positivos en propuestas automaticas.** Limite duro: max 5 por invocacion, evidencia obligatoria (snippet real), sin auto-merge. +- **Telemetria genera datos sensibles.** Mitigacion estructural: solo se guarda `args_hash`. Snippets de error redactados por allowlist. + +## Aprendizaje + +A 2026-05-14, primer grupo `cpp-windows` creado en respuesta directa al patron taskkill+cp+start repetido: `is_cpp_app_running_windows_bash_infra`, `launch_cpp_app_windows_bash_infra`, pipeline `redeploy_cpp_app_windows_bash_pipelines`, pagina madre `docs/capabilities/cpp-windows.md`. Validacion empirica del bucle: detectar -> construir -> agrupar -> documentar -> esperar reduccion de violations en proximas sesiones. + +Pendiente: medir el delta de `Reg %` y `violations_24h` a 7d para confirmar que el coste de descubrimiento bajo de "FTS5 N veces" a "1 read de pagina madre". diff --git a/docs/adr/README.md b/docs/adr/README.md index e01f06a2..c5081388 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -59,3 +59,6 @@ Qué se aprendió después. Útil cuando un ADR se supersede. | # | Título | Estado | |---|--------|--------| | [0001](0001-gitbutler-experiment.md) | Experimento con GitButler para trabajo paralelo | rejected | +| [0002](0002-apps-analyses-as-dataforge-master.md) | Apps y analyses como sub-repos `dataforge/` con branch master | accepted | +| [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted | +| [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted | diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 5682096d..d311542a 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -21,6 +21,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [metabase](metabase.md) | 106 | _(editar — promovido automaticamente)_ | | [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas | | [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) | +| [cpp-windows](cpp-windows.md) | 7 | Compilar, desplegar, lanzar y verificar apps C++ en Windows desde WSL2 | ## Como anadir grupo diff --git a/docs/capabilities/cpp-windows.md b/docs/capabilities/cpp-windows.md new file mode 100644 index 00000000..962bc597 --- /dev/null +++ b/docs/capabilities/cpp-windows.md @@ -0,0 +1,80 @@ +# cpp-windows + +Operar apps C++ del registry en Windows desde WSL2: compilar, desplegar, lanzar, verificar y relanzar. + +## Funciones del grupo + +| ID | Firma corta | Que hace | +|---|---|---| +| `build_cpp_windows_bash_infra` | `build_cpp_windows([target])` | Cross-compila apps C++ para Windows con mingw-w64 | +| `deploy_cpp_exe_to_windows_bash_infra` | `deploy_cpp_exe_to_windows(app_name, app_dir)` | Copia .exe + DLLs + assets al Desktop Windows, mata proceso previo | +| `launch_cpp_app_windows_bash_infra` | `launch_cpp_app_windows(app_name, [desktop_dir])` | Lanza .exe en Windows via cmd.exe /c start, retorna inmediatamente | +| `is_cpp_app_running_windows_bash_infra` | `is_cpp_app_running_windows(app_name)` | Exit 0 si el proceso esta vivo (tasklist.exe), stdout: `RUNNING: PID=N MEM=...K` | +| `launch_cpp_app_windows_bash_infra` | `launch_cpp_app_windows(app_name, [desktop_dir])` | Lanza .exe en Windows via cmd.exe /c start, retorna inmediatamente | +| `e2e_run_cpp_windows_bash_infra` | `e2e_run_cpp_windows(target, [--no-build], [--no-deploy])` | Build + deploy + run headless de app C++ (tests e2e tipo altsnap) | +| `redeploy_cpp_app_windows_bash_pipelines` | `redeploy_cpp_app_windows(app_name, app_dir, [--build])` | Pipeline completo: build? + deploy + launch + verify en un comando | + +## Ejemplo canonico + +### Redeploy rapido (build ya hecho) + +```bash +source bash/functions/infra/deploy_cpp_exe_to_windows.sh +source bash/functions/infra/launch_cpp_app_windows.sh +source bash/functions/infra/is_cpp_app_running_windows.sh + +deploy_cpp_exe_to_windows "registry_dashboard" \ + "/home/lucas/fn_registry/projects/fn_monitoring/apps/registry_dashboard" +launch_cpp_app_windows "registry_dashboard" +sleep 1 +if is_cpp_app_running_windows "registry_dashboard"; then + echo "OK: arrancado" +else + echo "ERROR: no arranco" >&2; exit 1 +fi +``` + +### Via pipeline (un solo comando) + +```bash +source bash/functions/pipelines/redeploy_cpp_app_windows.sh + +# Sin recompilar +redeploy_cpp_app_windows "registry_dashboard" \ + "/home/lucas/fn_registry/projects/fn_monitoring/apps/registry_dashboard" + +# Con recompilacion previa +redeploy_cpp_app_windows "chart_demo" \ + "/home/lucas/fn_registry/cpp/apps/chart_demo" --build +``` + +### Comprobar si esta vivo antes de decidir + +```bash +source bash/functions/infra/is_cpp_app_running_windows.sh + +if is_cpp_app_running_windows "registry_dashboard"; then + echo "Ya esta corriendo — skip launch" +else + launch_cpp_app_windows "registry_dashboard" +fi +``` + +## Fronteras + +- No gestiona builds Linux ni assets de Linux — solo el flujo Windows. +- No verifica que la app funcione correctamente, solo que el proceso existe (`tasklist.exe`). +- No gestiona credenciales ni configuracion de la app — eso es responsabilidad de cada app via `local_files/`. +- Tests automatizados en CI no son posibles — requieren WSL2 con Windows activo. + +## Prerequisitos + +- WSL2 con interop a Windows habilitado (`tasklist.exe`, `cmd.exe`, `wslpath` accesibles). +- `mingw-w64` instalado en WSL para `build_cpp_windows` (`sudo apt install mingw-w64`). +- El .exe compilado en `cpp/build/windows/apps//` antes de `deploy_cpp_exe_to_windows`. + +## Notas + +El flujo estandar es: `build_cpp_windows` → `deploy_cpp_exe_to_windows` → `launch_cpp_app_windows` → `is_cpp_app_running_windows`. El pipeline `redeploy_cpp_app_windows` encapsula los pasos 2-4 (con 1 opcional via `--build`). + +`deploy_cpp_exe_to_windows` ya incluye un `taskkill.exe` interno — no es necesario matar el proceso manualmente antes de llamarlo. `is_cpp_app_running_windows` es util cuando se quiere tomar la decision de matar/no matar de forma explicita antes del deploy. diff --git a/docs/diary/2026-05-14.md b/docs/diary/2026-05-14.md new file mode 100644 index 00000000..31b1497e --- /dev/null +++ b/docs/diary/2026-05-14.md @@ -0,0 +1,103 @@ +# 2026-05-14 + +## 01:35 — Telemetria de Claude como motor de capacidades + grupo cpp-windows + +El registry pasa a optimizarse sobre las propias ejecuciones del agente. Cada Bash, MCP call y heredoc queda capturado en `call_monitor.operations.db` (issue 0085) y alimenta el bucle: detectar patron inline -> proponer funcion -> agrupar en capability -> servir descubrimiento gratis al siguiente turno. + +- Hecho: ADR 0004 `0004-telemetry-driven-capability-growth.md` aceptado — formaliza el bucle telemetria + capability groups + discovery acceleration. +- Hecho: Indice ADR `docs/adr/README.md` ahora lista 0001..0004 (faltaban 0002 y 0003). +- Hecho: Grupo de capacidades `cpp-windows` creado como caso piloto del bucle: + - `bash/functions/infra/is_cpp_app_running_windows.sh` + `.md` — `is_cpp_app_running_windows_bash_infra`. + - `bash/functions/infra/launch_cpp_app_windows.sh` + `.md` — `launch_cpp_app_windows_bash_infra`. + - `bash/functions/pipelines/redeploy_cpp_app_windows.sh` + `.md` — `redeploy_cpp_app_windows_bash_pipelines` (build? + deploy + launch + verify). + - Pagina madre `docs/capabilities/cpp-windows.md` con lista, ejemplo canonico y fronteras. + - Fila anadida a `docs/capabilities/INDEX.md`. +- Hecho: Issue `dev/issues/0087-capability-discovery-acceleration.md` con modelo de 5 capas + plan en 3 tandas (A paralelo, B dependiente, C refuerzos). +- Hecho: Slash command `/fn_claude` (`.claude/commands/fn_claude.md`) describe AUDIT -> GAP -> PROPOSE -> CONSTRUCT -> VALIDATE -> SELF-TEST como flujo autonomo. Limite duro max 5 funciones por invocacion, sin auto-merge. +- Hecho: `.claude/CLAUDE.md` raiz lleva ahora seccion "Delegacion + Capability Groups" como REGLA DURA (issue 0086). + +### Por que ahora + +Sesion 2026-05-13/14: Claude reinvento `taskkill + cp Desktop + cmd /c start` cinco veces pese a tener `deploy_cpp_exe_to_windows_bash_infra`. Evidencia directa de que sin telemetria + descubrimiento barato las reglas son aspiracion. La respuesta no es regla mas estricta, es **infraestructura para que la regla se cumpla**. + +### Las 3 metricas norte (Monitor tab del `registry_dashboard`) + +- `Reg %` — fraccion de calls con `function_id != ''`. Sube cuando se usa registry. +- `MCP ratio` — adopcion de patrones canonicos. +- `violations_24h` — antipatrones detectados. Baja cuando el bucle cierra. + +### Pendiente + +- Implementar Tanda A de 0087 en paralelo: `fn doctor capabilities --emit-claude-md`, binario `fn_match`, validator de IDs en `fn_create_function`, clausula hooks en `registry_calls.md`. +- Medir delta `Reg %` y `violations_24h` a 7d. Si no se mueven, replantear capas 1-2. +- Hook `PreToolUse` con `fn_match` requiere benchmark <50ms antes de habilitar global. + +Referencias: ADR 0004, issue 0085, issue 0086, issue 0087, `docs/capabilities/cpp-windows.md`. + +## 02:00 — Tandas A+B de issue 0087 cerradas + fn_match tuning + Failed Functions sub-tab + +### Tanda A (paralelo, 3 fn-constructor + edits directos) + +- `cmd/fn/match.go` — subcommand `fn match ""`. Tokeniza comando, FTS5 query sobre `functions_fts`, re-score aditivo (name=+3, tags=+2, signature=+1.5, description=+1). Latencia 6-7ms. Devuelve `top[]` con `score` (normalizado top=1.0) y `raw_score` (absoluto pre-normalizacion). Bug fix critico: `high_confidence` ahora se computa sobre `raw_score`, no `score` — antes era SIEMPRE true por la normalizacion. +- `cmd/fn/doctor.go` — flag `--emit-claude-md` en subcomando `capabilities`. Emite bloque markdown con secciones TOP 20 (por `calls_total`), Fresh 7d, Pipelines top 5. Fallback si `call_monitor.operations.db` ausente. +- `functions/infra/emit_capabilities_md.go` — `EmitCapabilitiesMd` + `RenderCapabilitiesMd` (paquete `infra`). +- `projects/fn_monitoring/apps/call_monitor/sequences.go` + `migrations/006_function_sequences.sql` — subcomando `call_monitor sequences --detect [--propose]`. Detecta secuencias A->B(->C) con: same session, gap < 30s, occ >= 5, sess >= 2, success_rate >= 0.9. Genera proposals `new_pipeline` con evidencia. 41 secuencias capturadas en primer run; 0 candidatas (umbrales no alcanzados todavia). +- `.claude/rules/registry_calls.md` — clausula nueva: hooks e infraestructura de telemetria pueden leer `registry.db` directo con conexion read-only. NO sujeto a regla MCP-first porque no es accion del agente. +- `.claude/rules/function_growth_and_self_docs.md` (nuevo) + fila en `INDEX.md` — contrato `.md` autosuficiente (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** (no por inflar funciones individuales). Issue 0087. +- `.claude/CLAUDE.md` — Norte ampliado a 4 objetivos (anadido `PROMOVER COMPOSICIONES A PIPELINES`). Linea sobre auto-discovery zero-second-lookup. + +### Tanda B (3 general-purpose en paralelo) + +- `.claude/scripts/hook_fn_match.sh` (PreToolUse, Bash matcher) — denylist temprana (cmds < 20 chars, `ls/cd/pwd/cat/echo/grep`, `./fn run`, pure-cd). Llama `fn match` con `timeout 0.2`. Trigger: `high_confidence==true OR raw_score >= ~5`. Imprime `FUZZY-MATCH: USE ./fn run ` a stderr. Latencia path trigger ~113ms, denylist ~32ms. +- `.claude/scripts/hook_capabilities_inject.sh` (UserPromptSubmit) — cache `~/.cache/fn_registry/capabilities.txt` TTL 1h. Llama `fn doctor capabilities --emit-claude-md`, awk-parse 3 secciones, emite JSON `hookSpecificOutput.additionalContext` con linea compacta `CAPABILITIES (cache 1h): TOP / FRESH / PIPELINES`. Latencia cold 33ms, warm 18ms. +- `~/.config/systemd/user/call_monitor_sequences.{service,timer}` + copias versionadas en `projects/fn_monitoring/apps/call_monitor/systemd/`. Timer OnCalendar `*-*-* 00/6:00:00` (cada 6h). Persistent=true. Service activo y verificado (exit 0). + +### Tuning de fn_match + +Tres iteraciones del threshold de `high_confidence`: + +- v1: `top1.score / top2.score > 1.5` (normalizado). Resultado: SIEMPRE true. Inutil. +- v2: `raw_score >= 3.0`. Mejor pero deja falsos positivos en queries debiles (raw 3.2-3.65). +- v3: `raw_score >= 5.0`. Mata caso ejemplar `taskkill` (raw 4.06). Demasiado estricto. +- **v4 final**: `raw_score >= 4.0`. Balance ~93% precision contra patrones de `analysis/domain_coverage_gaps`. Prefer silencio en queries cortas; acepta 1 falso positivo conocido (`robots.txt user-agent` -> `agent_scaffold` por token "agent"). + +### Dashboard updates (sub-repo `registry_dashboard`) + +- Migration 007 `projects/fn_monitoring/apps/call_monitor/migrations/007_calls_command_snippet.sql` — anade columna `command_snippet TEXT` a `calls`. Aditiva, idempotente. Solo se rellena cuando `function_id == ''`. Redactado de secrets (`password=`, `token=`, `secret=`, `api_key=`, `bearer=`) antes de persistir. +- `.claude/scripts/hook_call_monitor.sh` — `insert_call` acepta 4to arg `snippet`. Solo escribe si `fn_id` vacio. Llamadores Bash pasan `CMD_HEAD` (200 chars). +- Dashboard Recent Executions: columna `Function` muestra snippet `$ ` en gris cuando `function_id` vacio. Hover tooltip con snippet completo. +- Nueva sub-tab `Failed Functions` (5a): subset de `recent_executions` con `function_id != '' AND !success`. Columnas When/Function/Tool/Error class/Error snippet. Util para diagnostico cuando objetivo 1+2 caen. +- ImPlot scatter: + - Eje Y dinamico `0..max(visible) + 500ms` (rescale evita pillarse en picos viejos fuera de ventana). + - Combo `Scatter:` en toolbar (1m/5m/15m/1h/6h), independiente del filtro de KPIs. + - `ImPlot::GetStyle().UseLocalTime = true` — el eje X ahora muestra hora local, no UTC. + - `ImPlotAxisFlags_NoHighlight` en X y Y — sin iluminacion ruidosa al hover. + - Hover tooltip sobre punto: When, Function, Tool, Duration, Error (si fail). + +### Bug fix critico: launch_cpp_app_windows + +`cmd.exe /c "cd /d \"$dir\" && start \"$app\" \"$app.exe\""` rompia en cmd.exe — el `\"` final tras path termiando en `\` se interpretaba como escape de comilla, dejando string sin cerrar -> error "Windows cannot find \\". Fix: reescribir a PowerShell `Start-Process -FilePath ... -WorkingDirectory ...` que usa quoting simple literal. + +### Discovery quality (tests vs fn_monitoring/analysis/domain_coverage_gaps) + +Patrones del analysis testeados contra `fn match`: + +- Hits correctos `high=true`: sharpe ratio, monte carlo, cdp browse, websocket stream, stable diffusion, taskkill exe. +- Falsos negativos coherentes `high=false`: binance kline, kelly criterion, VaR conditional, OLS regression, vectorized backtest, robots.txt -> coincide con gaps reales del analysis. +- Falso positivo persistente: 1 de 14 (robots.txt -> agent_scaffold). +- Cobertura validada: finance/browser/infra/ml estan cubiertos. Trading/quant/scraping confirmados como gaps reales del analysis. + +### Validacion end-to-end + +- PreToolUse hook test: `echo '{...taskkill...}' | bash .claude/scripts/hook_fn_match.sh` -> FUZZY-MATCH con `deploy_cpp_exe_to_windows`. +- UserPromptSubmit hook: cada turno user injecta `CAPABILITIES (cache 1h): TOP / FRESH / PIPELINES`. +- Timer: `systemctl --user list-timers` muestra proxima ejecucion. Manual run exit 0. +- Pipeline `redeploy_cpp_app_windows` ejecutado 3+ veces esta sesion via `./fn run`. Reemplaza ~6 commands manuales con 1 invocacion. + +### Pendiente + +- 2 bugs cosmeticos no bloqueantes: `cp: Permission denied` benigno en deploy (rsync salva); `PID=RUNNING: PID=N MEM=K` formato raro en mensaje final del pipeline redeploy. +- Tanda C de 0087 (refuerzos): `/fn_claude` auto-append a `MEMORY.md` al crear funciones, naming validator soft. +- Esperar telemetria de 7d para medir delta `Reg %` y `violations_24h`. Si no se mueven, replantear capa 3 (interceptor PreToolUse mas agresivo). + +Referencias: ADR 0004, issues 0085/0086/0087, `.claude/rules/function_growth_and_self_docs.md`, `.claude/scripts/hook_{fn_match,capabilities_inject}.sh`, `cmd/fn/match.go`. diff --git a/functions/infra/emit_capabilities_md.go b/functions/infra/emit_capabilities_md.go new file mode 100644 index 00000000..24849eba --- /dev/null +++ b/functions/infra/emit_capabilities_md.go @@ -0,0 +1,318 @@ +package infra + +import ( + "database/sql" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// CapabilityEntry represents one function/pipeline line in the emitted markdown. +type CapabilityEntry struct { + ID string + Signature string + Description string + UpdatedAt string // RFC3339 or date only, used for Fresh section +} + +// CapabilitiesMdResult holds the three sections for the emitted markdown block. +type CapabilitiesMdResult struct { + Top20 []CapabilityEntry // top 20 by calls_total (or fallback updated_at) + Fresh7d []CapabilityEntry // updated in last 7 days + TopPipelines []CapabilityEntry // top 5 pipelines by calls_total (or updated_at) + TelemetryAvail bool // false = call_monitor.db not found or empty + GeneratedAt string // RFC3339 timestamp +} + +const capMdMaxDescLen = 80 + +func truncateDesc(s string) string { + // strip newlines so description stays on one line + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) > capMdMaxDescLen { + return s[:capMdMaxDescLen-3] + "..." + } + return s +} + +// EmitCapabilitiesMd generates the markdown block for embedding in CLAUDE.md. +// It queries: +// 1. registry.db (root) for function metadata (signature, description, updated_at). +// 2. call_monitor/operations.db (projects/fn_monitoring/apps/call_monitor/) for +// function_stats.calls_total. Falls back gracefully when unavailable. +// +// Exclusions: +// - kind = "component" (frontend components, handled separately) +// - lang = "cpp" AND file_path LIKE 'cpp/apps/%' (auto-touched C++ app files) +// - tags containing "pendiente-usar" (orphaned/deprecated) +func EmitCapabilitiesMd(registryRoot string) (CapabilitiesMdResult, error) { + result := CapabilitiesMdResult{ + GeneratedAt: time.Now().UTC().Format("2006-01-02T15:04"), + } + + // --- 1. Open registry.db --- + regDB, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", + filepath.Join(registryRoot, "registry.db"))) + if err != nil { + return result, fmt.Errorf("emit_capabilities_md: open registry.db: %w", err) + } + defer regDB.Close() + if err := regDB.Ping(); err != nil { + return result, fmt.Errorf("emit_capabilities_md: ping registry.db: %w", err) + } + + // --- 2. Try opening call_monitor/operations.db --- + monitorDBPath := filepath.Join(registryRoot, + "projects", "fn_monitoring", "apps", "call_monitor", "operations.db") + monitorDB, monErr := sql.Open("sqlite3", + fmt.Sprintf("file:%s?mode=ro", monitorDBPath)) + if monErr == nil { + if pingErr := monitorDB.Ping(); pingErr != nil { + monitorDB.Close() + monitorDB = nil + } else { + result.TelemetryAvail = true + } + } + if monitorDB != nil { + defer monitorDB.Close() + } + + // --- 3. Load call counts from function_stats if available --- + // map[function_id]calls_total + callCounts := map[string]int64{} + if result.TelemetryAvail { + rows, err := monitorDB.Query( + `SELECT function_id, calls_total FROM function_stats WHERE calls_total > 0`) + if err == nil { + defer rows.Close() + for rows.Next() { + var fid string + var ct int64 + if rows.Scan(&fid, &ct) == nil { + callCounts[fid] = ct + } + } + } + } + + // --- 4. Load eligible functions from registry.db --- + // Eligible: kind IN ('function','pipeline'), not component, not cpp/apps/, not pendiente-usar + type fnRow struct { + id string + kind string + signature string + description string + updatedAt string + tags string // JSON array + } + + rows, err := regDB.Query(` + SELECT id, kind, signature, description, updated_at, tags + FROM functions + WHERE kind IN ('function', 'pipeline') + AND NOT (lang = 'cpp' AND file_path LIKE 'cpp/apps/%') + ORDER BY updated_at DESC + `) + if err != nil { + return result, fmt.Errorf("emit_capabilities_md: query functions: %w", err) + } + defer rows.Close() + + var allFns []fnRow + for rows.Next() { + var r fnRow + if err := rows.Scan(&r.id, &r.kind, &r.signature, &r.description, &r.updatedAt, &r.tags); err != nil { + continue + } + // Exclude pendiente-usar + var tagList []string + _ = json.Unmarshal([]byte(r.tags), &tagList) + pending := false + for _, t := range tagList { + if strings.TrimSpace(t) == "pendiente-usar" { + pending = true + break + } + } + if pending { + continue + } + allFns = append(allFns, r) + } + if err := rows.Err(); err != nil { + return result, fmt.Errorf("emit_capabilities_md: rows: %w", err) + } + + // --- 5. Build Top20 (functions only, kind=function) by calls_total desc --- + // When telemetry unavailable, order by updated_at desc (already sorted that way). + type scored struct { + fnRow + score int64 // calls_total if telemetry, else 0 (order preserved) + } + + var fnOnly []scored + var pipelines []scored + for _, r := range allFns { + s := scored{fnRow: r, score: callCounts[r.id]} + if r.kind == "pipeline" { + pipelines = append(pipelines, s) + } else { + fnOnly = append(fnOnly, s) + } + } + + // Sort functions by score desc (stable: updated_at desc is the tiebreaker from query order) + if result.TelemetryAvail { + stableSort(fnOnly, func(a, b scored) bool { return a.score > b.score }) + stableSort(pipelines, func(a, b scored) bool { return a.score > b.score }) + } + // else: already in updated_at DESC from SQL + + // Top 20 functions + top := min20(fnOnly) + for _, s := range top { + result.Top20 = append(result.Top20, CapabilityEntry{ + ID: s.id, + Signature: s.signature, + Description: truncateDesc(s.description), + UpdatedAt: dateOnly(s.updatedAt), + }) + } + + // --- 6. Fresh 7d (functions + pipelines updated in last 7 days) --- + cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) + seen := map[string]bool{} + // Mark top20 IDs so we can still include them in fresh if they were touched + for _, e := range result.Top20 { + seen[e.ID] = false // false = not yet in fresh + } + for _, r := range allFns { + t, err := parseTime(r.updatedAt) + if err != nil || t.Before(cutoff) { + continue + } + // include any kind (function or pipeline) touched in last 7d + result.Fresh7d = append(result.Fresh7d, CapabilityEntry{ + ID: r.id, + Signature: r.signature, + Description: truncateDesc(r.description), + UpdatedAt: dateOnly(r.updatedAt), + }) + } + + // --- 7. Top 5 pipelines --- + top5 := min5(pipelines) + for _, s := range top5 { + result.TopPipelines = append(result.TopPipelines, CapabilityEntry{ + ID: s.id, + Signature: s.signature, + Description: truncateDesc(s.description), + UpdatedAt: dateOnly(s.updatedAt), + }) + } + + return result, nil +} + +// RenderCapabilitiesMd formats CapabilitiesMdResult as a markdown string. +func RenderCapabilitiesMd(r CapabilitiesMdResult) string { + var sb strings.Builder + + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("\n\n", r.GeneratedAt)) + + // Section 1: Top 20 + if !r.TelemetryAvail { + sb.WriteString("## Capabilities — Top 20 (by updated_at, telemetry unavailable)\n\n") + } else { + sb.WriteString("## Capabilities — Top 20 (by calls_total)\n\n") + } + if len(r.Top20) == 0 { + sb.WriteString("_(no functions found)_\n") + } + for _, e := range r.Top20 { + sig := e.Signature + if sig == "" { + sig = e.ID + } + sb.WriteString(fmt.Sprintf("- `%s` — `%s` — %s\n", e.ID, sig, e.Description)) + } + + // Section 2: Fresh 7d + sb.WriteString("\n## Capabilities — Fresh (last 7d)\n\n") + if len(r.Fresh7d) == 0 { + sb.WriteString("_(no functions updated in the last 7 days)_\n") + } + for _, e := range r.Fresh7d { + sb.WriteString(fmt.Sprintf("- `%s` (%s) — %s\n", e.ID, e.UpdatedAt, e.Description)) + } + + // Section 3: Top 5 pipelines + sb.WriteString("\n## Pipelines — Top 5 (one-shot composites)\n\n") + if len(r.TopPipelines) == 0 { + sb.WriteString("_(no pipelines found)_\n") + } + for _, e := range r.TopPipelines { + sig := e.Signature + if sig == "" { + sig = e.ID + } + sb.WriteString(fmt.Sprintf("- `%s` — `%s` — %s\n", e.ID, sig, e.Description)) + } + + return sb.String() +} + +// --- helpers --- + +func stableSort[T any](slice []T, less func(a, b T) bool) { + // insertion sort — small slices (<500), stable + for i := 1; i < len(slice); i++ { + for j := i; j > 0 && less(slice[j], slice[j-1]); j-- { + slice[j], slice[j-1] = slice[j-1], slice[j] + } + } +} + +func min20[T any](s []T) []T { + if len(s) <= 20 { + return s + } + return s[:20] +} + +func min5[T any](s []T) []T { + if len(s) <= 5 { + return s + } + return s[:5] +} + +func dateOnly(ts string) string { + // "2026-05-14T01:30:00Z" -> "2026-05-14" + if len(ts) >= 10 { + return ts[:10] + } + return ts +} + +func parseTime(ts string) (time.Time, error) { + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.Parse(f, ts); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse time: %q", ts) +}