Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e6e27ad1 | |||
| a59b12d467 | |||
| fe4320af89 | |||
| f71e0f4c9a | |||
| 46b4385331 | |||
| 580238b32e | |||
| ed767360c1 | |||
| 5bac05ce13 | |||
| d0ceea6f3d | |||
| 0f905b78e0 |
@@ -27,6 +27,8 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E
|
||||
|
||||
**Reglas y convenciones:** ver `.claude/rules/INDEX.md`
|
||||
|
||||
**Slash commands:** `/commands` lista todos los slash commands del repo agrupados por namespace (global + projects). Project commands viven en `projects/<p>/.claude/commands/` y se exponen como `/<project>:<cmd>` via symlink. Ver `.claude/rules/project_commands.md`.
|
||||
|
||||
**Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`.
|
||||
|
||||
---
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../projects/aurgi/.claude/commands
|
||||
@@ -1,121 +1,36 @@
|
||||
# /autonomous-task — Lanza fn-orquestador (Fase 6 del ciclo reactivo)
|
||||
|
||||
Lanza el meta-orquestador autonomo que recorre el bucle CONSTRUIR → EJECUTAR → RECOPILAR → ANALIZAR → MEJORAR sobre un issue, sin intervencion humana, hasta convergencia / estancamiento / timeout / limite de iteraciones.
|
||||
|
||||
Issue 0069. Pre-condiciones obligatorias (chequear ANTES de despachar):
|
||||
|
||||
1. Migration `fn_operations/migrations/006_task_runs.sql` aplicada.
|
||||
2. Subagentes `fn-constructor`, `fn-executor`, `fn-recopilador`, `fn-analizador`, `fn-mejorador`, `fn-orquestador` presentes en `.claude/agents/`.
|
||||
3. `dev/autonomous_protected_paths.json` existe.
|
||||
4. `master` local up-to-date con `origin/master`.
|
||||
5. Branch `auto/<issue_id>` NO existe ya.
|
||||
6. `gh auth status` OK (necesario para PR draft al converger).
|
||||
7. Tipo de tarea soportado: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`.
|
||||
|
||||
Si alguna pre-condicion falla → ABORT con razon. NO improvisar.
|
||||
|
||||
---
|
||||
description: "DEPRECADO 2026-05-19 — usa /autopilot. Wrapper directo a fn-orquestador conservado solo como debug primitive."
|
||||
---
|
||||
|
||||
## Argumento
|
||||
# /autonomous-task — DEPRECADO (sustituido por `/autopilot`)
|
||||
|
||||
`$ARGUMENTS` — `<issue_id>` o `<task_spec_path>` + flags opcionales.
|
||||
**ESTADO:** deprecado 2026-05-19. Usa `/autopilot <NNNN>` en su lugar.
|
||||
|
||||
```
|
||||
/autonomous-task 0070
|
||||
/autonomous-task 0070 --max-iterations 15 --max-minutes 90
|
||||
/autonomous-task 0070 --auto-apply-proposals safe
|
||||
/autonomous-task 0070 --dry-run
|
||||
/autonomous-task path/to/spec.yaml --branch auto/custom-name
|
||||
```
|
||||
## Por que deprecado
|
||||
|
||||
Flags:
|
||||
- `--max-iterations N` tope de iteraciones (default 10)
|
||||
- `--max-minutes M` timeout total (default 60)
|
||||
- `--auto-apply-proposals` `none|safe|aggressive` (default `safe`)
|
||||
- `--branch NAME` rama TBD (default `auto/<issue_id>`)
|
||||
- `--dry-run` simula, NO aplica
|
||||
`/autopilot` (v2, 2026-05-19) absorbe la funcionalidad y anade:
|
||||
- Pre-flight DoD readiness check (gate STOP — no arranca sin DoD).
|
||||
- Detector issue vs flow.
|
||||
- Reporte estructurado al humano post-delegate.
|
||||
- Self-Q&A migrado a fn-orquestador.
|
||||
|
||||
---
|
||||
Behaviour orquestador-side es identico. La unica diferencia es que `/autopilot` valida antes de delegar; `/autonomous-task` delegaba ciego.
|
||||
|
||||
## Comportamiento
|
||||
## Sustitucion 1:1
|
||||
|
||||
1. **Verificar pre-condiciones** con script bash (ver arriba). Si alguna falla, reportar y salir.
|
||||
2. **Despachar a `fn-orquestador`** via Agent tool con `subagent_type=fn-orquestador`. Pasar:
|
||||
- `issue_id` o `task_spec`
|
||||
- flags resueltos
|
||||
- paths protegidos (leidos de `dev/autonomous_protected_paths.json`)
|
||||
3. **El subagente:**
|
||||
- Crea worktree aislado `/tmp/fn_orq_<issue>_<ts>/` desde `master`.
|
||||
- Persiste estado en `task_runs` (operations.db del app target o repo root).
|
||||
- Despacha por fases a los 5 subagentes especializados.
|
||||
- Aplica proposals filtradas por `--auto-apply-proposals`.
|
||||
- Termina con: `converged` (PR draft creado) | `stalled` | `timeout` | `iterations_exhausted` | `needs_human` | `aborted`.
|
||||
4. **Reportar resultado al humano** con:
|
||||
- `status`, `iterations / max`, `duration / max`
|
||||
- `branch`, `worktree`, `PR draft url` si converged
|
||||
- `proposals creadas / aplicadas`
|
||||
- `last run_id` y status
|
||||
- Resumen iter-por-iter del `progress_json`
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15 --max-minutes 90` | `/autopilot 0070 --max-iterations 15 --max-minutes 90` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
| `/autonomous-task 0070 --auto-apply-proposals safe` | `/autopilot 0070 --auto-apply-proposals safe` |
|
||||
|
||||
---
|
||||
## Modo debug
|
||||
|
||||
## Reglas duras (no negociables)
|
||||
Si `/autopilot` falla en pre-flight pero quieres forzar dispatch sin DoD check (debug / experimentos), puedes seguir usando `/autonomous-task` que va directo a `fn-orquestador` sin validar. NO RECOMENDADO para uso normal.
|
||||
|
||||
- Sandbox de rama EN WORKTREE — nunca toca master ni el working tree del humano.
|
||||
- No merge automatico — PR draft siempre.
|
||||
- No `--no-verify`, no `--force`, no skip hooks.
|
||||
- Paths protegidos via `dev/autonomous_protected_paths.json`.
|
||||
- Watchdog: 2 iteraciones con mismo set de fails → `status=stalled`.
|
||||
- Auditoria total en `task_runs.progress_json`.
|
||||
- No self-modification: NO toca `.claude/agents/` ni `.claude/commands/`.
|
||||
## Migration deadline
|
||||
|
||||
---
|
||||
Sin deadline duro — `/autonomous-task` seguira funcionando hasta que un commit lo elimine. Pero NO se anaden nuevas features aqui; cualquier mejora va a `/autopilot`.
|
||||
|
||||
## Integracion con call_monitor (issue 0085)
|
||||
|
||||
El orquestador puede leer `projects/fn_monitoring/apps/call_monitor/operations.db` para:
|
||||
|
||||
- Consultar `function_stats` antes de decidir que funciones usar/reusar.
|
||||
- Filtrar proposals existentes via `mcp__registry__fn_proposal --status pending` para evitar duplicados.
|
||||
- Loggear sus invocaciones via el hook PostToolUse (automatico).
|
||||
|
||||
Tras converger, el `call_monitor propose` ejecutado por el humano (o futuro cron) absorbera las nuevas violations / copied_code / fails para alimentar la siguiente ronda.
|
||||
|
||||
---
|
||||
|
||||
## Tipos NO soportados
|
||||
|
||||
- Diseño arquitectura nuevo (humano decide).
|
||||
- Decisiones UX subjetivas.
|
||||
- Cambios BD productiva.
|
||||
- Cualquier cosa que toque secrets/credenciales.
|
||||
- Self-modification del propio orquestador.
|
||||
|
||||
Si el issue contiene criterios no-verificables programaticamente, ABORT con `status=needs_human`.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autonomous-task: 0070 ===
|
||||
status: converged
|
||||
iterations: 7 / 10
|
||||
duration: 23 min / 60
|
||||
branch: auto/0070
|
||||
worktree: /tmp/fn_orq_0070_1731612345
|
||||
PR draft: https://github.com/.../pull/123
|
||||
proposals: 3 creadas, 2 auto-aplicadas
|
||||
last run_id: e2e_run_abc123 (status: pass)
|
||||
|
||||
Iter:
|
||||
1. construir → ok (2 funciones nuevas)
|
||||
2. ejecutar → ok
|
||||
3. analizar → fail (2/8 checks)
|
||||
4. mejorar → 3 proposals (2 auto-applicadas)
|
||||
5. construir → ok (re-build tras patches)
|
||||
6. analizar → pass
|
||||
7. recopilador → ok (operations.db integra)
|
||||
|
||||
Siguiente: revisar PR draft + fn proposal list -s pending --target-id 0070
|
||||
```
|
||||
Ver `.claude/commands/autopilot.md` para spec completa.
|
||||
|
||||
+132
-260
@@ -1,238 +1,68 @@
|
||||
---
|
||||
name: autopilot
|
||||
description: Modo full-auto self-Q&A. Toma issue o flow con DoD definido, valida readiness, ejecuta hasta cerrarlo sin interaccion humana. Ante cada decision se autoformula la pregunta, se autoresponde con razonamiento explicito, y avanza. Spawnea subagentes. Para.
|
||||
description: Modo full-auto. Pre-flight DoD check, detecta issue vs flow, SIEMPRE delega a fn-orquestador (worktree aislado + PR Gitea). Sin Path inline. Sustituye a /autonomous-task.
|
||||
---
|
||||
|
||||
# /autopilot — Modo autonomo end-to-end con self-Q&A
|
||||
# /autopilot — Comando autonomo unificado
|
||||
|
||||
Ejecuta un issue o flow **hasta cierre** sin intervencion humana. Ante cada decision, Claude **se formula la pregunta a si mismo y se la responde** (self-Q&A) con razonamiento trazable, en vez de abortar. Auto-prefiere la opcion **Recomendada** cuando exista; cuando no, decide en base a evidencia del codigo, registry, y reglas. Cada Q&A queda persistido en `task_runs.events_json[]` para auditoria. Spawnea subagentes en paralelo y persiste estado en `task_runs`.
|
||||
Comando UNICO para ejecutar issue o flow autonomo end-to-end. Sustituye a `/autonomous-task` (deprecado). Hace dos cosas:
|
||||
|
||||
Diferencia con comandos relacionados:
|
||||
1. **Pre-flight DoD readiness check** — sin DoD claro, no arranca.
|
||||
2. **Delega SIEMPRE a `fn-orquestador`** via Agent tool — worktree aislado en `/tmp/fn_orq_<NNNN>_<ts>/`, branch `auto/<NNNN>-<slug>`, PR draft Gitea al converger.
|
||||
|
||||
| Comando | Que hace |
|
||||
|---|---|
|
||||
| `/autonomous-task <issue>` | Wrapper directo de `fn-orquestador` (registry-bound, bucle 5 fases) |
|
||||
| `/fix-issue <issue>` | Flujo guiado humano: rama + tasks + tests + version + close (pregunta cuando duda) |
|
||||
| `/flow run <NNNN>` | Runner manual de flows (fase 2 — no implementado) |
|
||||
| `/autopilot <target>` | **Meta-dispatcher**. Detecta issue vs flow, valida DoD, dispatch correcto, auto-defaults SIEMPRE |
|
||||
NO ejecuta nada inline. NO muta cwd del shell del humano. NO duplica worktrees. Toda la complejidad de bucle + paths protegidos + sanity check vive en `fn-orquestador`.
|
||||
|
||||
---
|
||||
## Por que solo delegar
|
||||
|
||||
Historico: versiones anteriores de `/autopilot` tenian Path A (delegate a orquestador), Path B (registry-only inline), Path C (flow inline). Los Path B/C reimplementaban lo que ya hace `fn-orquestador` (worktree, branch, PR) y arrastraban un bug: `cd` en Bash de Claude Code PERSISTE entre llamadas → si autopilot hace `cd "$WT"`, todos los Bash subsiguientes operan en branch incorrecta. Solucion: NO hacer Path inline, delegar siempre.
|
||||
|
||||
`fn-orquestador` ahora soporta dos `task_type`:
|
||||
- `issue` — flujo CONSTRUIR→EJECUTAR→RECOPILAR→ANALIZAR→MEJORAR (default).
|
||||
- `flow` — parsea `dev/flows/<NNNN>-*.md` ## Flow y ejecuta steps (Path C absorbido).
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/autopilot <NNNN> # issue NNNN (default si no hay prefijo)
|
||||
/autopilot issue:<NNNN> # issue NNNN explicito
|
||||
/autopilot i:<NNNN> # alias issue
|
||||
/autopilot issue:<NNNN> # issue explicito
|
||||
/autopilot i:<NNNN> # alias
|
||||
/autopilot flow:<NNNN> # flow NNNN
|
||||
/autopilot f:<NNNN> # alias flow
|
||||
/autopilot check <target> # solo audita DoD readiness, no ejecuta
|
||||
/autopilot <target> --max-iterations N --max-minutes M
|
||||
/autopilot <target> --dry-run
|
||||
/autopilot f:<NNNN> # alias
|
||||
/autopilot check <target> # solo audita DoD readiness, no delega
|
||||
/autopilot <target> --max-iterations N --max-minutes M --dry-run
|
||||
```
|
||||
|
||||
Detector:
|
||||
- `^\d{4}[a-z]?$` -> issue (sin prefijo = issue por defecto).
|
||||
- `^(issue|i):\d{4}[a-z]?$` -> issue.
|
||||
- `^(flow|f):\d{4}$` -> flow.
|
||||
- Otra cosa -> ABORT con error de sintaxis.
|
||||
- `^\d{4}[a-z]?$` → issue (sin prefijo = issue por defecto).
|
||||
- `^(issue|i):\d{4}[a-z]?$` → issue.
|
||||
- `^(flow|f):\d{4}$` → flow.
|
||||
- Otra cosa → ABORT con error de sintaxis.
|
||||
|
||||
---
|
||||
## Pre-flight DoD readiness check (OBLIGATORIO)
|
||||
|
||||
## Pre-flight: DoD readiness check (OBLIGATORIO)
|
||||
|
||||
Sin DoD claro, autopilot no arranca. La verificacion es STOP-gate, no se rellena por inferencia.
|
||||
Sin DoD claro, autopilot no delega. Verificacion es STOP-gate.
|
||||
|
||||
### Issue (`dev/issues/<NNNN>-*.md`)
|
||||
|
||||
Lee el .md. Debe cumplir **todos** estos:
|
||||
|
||||
1. Archivo existe en `dev/issues/` (no en `completed/`).
|
||||
2. Frontmatter valido (`status`, `priority`).
|
||||
3. **Al menos UNA** de:
|
||||
- Seccion `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- Seccion `## Acceptance` con checkboxes `[ ]`.
|
||||
- Seccion `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo soportado por `/autonomous-task` si va a delegar a orquestador (`feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`). Si no entra en estos, autopilot intenta ruta `/fix-issue` simplificada (registry-only changes sin rama).
|
||||
5. **NO** contiene criterios no-verificables: "queda bonito", "es intuitivo", "UX mejor". Heuristica grep simple — si match -> warning + ABORT.
|
||||
|
||||
Si falla -> ABORT con tabla:
|
||||
|
||||
```
|
||||
=== autopilot check 0107c ===
|
||||
status: NOT READY
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance/Tests
|
||||
- Frontmatter sin priority
|
||||
fix:
|
||||
- Anadir `## DoD` con 3-5 bullets verificables programaticamente
|
||||
- Anadir `priority: medium` al frontmatter
|
||||
```
|
||||
2. Frontmatter con `status`, `priority`.
|
||||
3. Al menos UNA de:
|
||||
- `## DoD` o `## Definition of Done` con >=1 bullet/checkbox concreto.
|
||||
- `## Acceptance` con checkboxes `[ ]`.
|
||||
- `## Tests` + `## Tareas` ambas no vacias.
|
||||
4. Tipo declarado/inferible soportado por `fn-orquestador`: `feature_app_simple`, `bugfix_with_repro`, `refactor_safe`, `add_e2e_check`, `feature_registry_only`.
|
||||
5. NO contiene criterios no-verificables ("queda bonito", "intuitivo", "UX mejor"). Grep simple; si match → ABORT con warning.
|
||||
|
||||
### Flow (`dev/flows/<NNNN>-*.md`)
|
||||
|
||||
1. Archivo existe en `dev/flows/` (no en `completed/`).
|
||||
1. Archivo existe en `dev/flows/`.
|
||||
2. Frontmatter valido.
|
||||
3. Seccion `## Acceptance` con >=1 checkbox `[ ]` (o ya `[x]` — significa parcialmente progresado).
|
||||
4. Seccion `## Flow` no vacia.
|
||||
5. Pre-requisitos declarados (incluso si vacio explicito).
|
||||
6. Tabla de funciones recomendadas presente — sin `FALTA: crear <id>` no resuelto (si hay `FALTA`, ABORT con lista de funciones a crear primero).
|
||||
3. `## Acceptance` con >=1 checkbox.
|
||||
4. `## Flow` no vacio.
|
||||
5. Pre-requisitos declarados.
|
||||
6. Tabla de funciones recomendadas sin `FALTA: crear <id>` (si los hay → ABORT salvo `--allow-construct-missing`).
|
||||
|
||||
Si `FALTA` esta presente: opcionalmente autopilot ofrece spawnear `fn-constructor` para cada FALTA — pero ESO solo si `--allow-construct-missing`. Por defecto ABORT y reportar.
|
||||
|
||||
---
|
||||
|
||||
## Modo autonomo (reglas duras de comportamiento)
|
||||
|
||||
Durante toda la ejecucion de `/autopilot`:
|
||||
|
||||
1. **NO invocar `AskUserQuestion` al humano**. En su lugar, **self-Q&A loop**: cuando surja una decision, Claude la formaliza como `Question -> Options -> Reasoning -> Choice` y persiste el bloque en `task_runs.events_json[]`. Ver seccion "Self-Q&A loop" mas abajo. Solo ABORTA con `status=needs_human` si la decision toca: (a) destructivo sin rollback (`--force`, `git reset --hard`, `DROP TABLE`), (b) credenciales/secrets, (c) paths protegidos, (d) contradice DoD explicito del issue. En esos casos, NUNCA self-answer — escala al humano.
|
||||
2. **Auto-pick "Recommended"** en cualquier flag con opciones (mocks vs prod, default branch, etc.) — usar primer item etiquetado como recomendado en el archivo / convencion del proyecto. Si no hay marcado, self-Q&A con justificacion.
|
||||
3. **Acciones destructivas prohibidas sin flag explicito**: `git reset --hard`, `git push --force`, `rm -rf` fuera de `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`. Si una accion las requiere -> ABORT.
|
||||
4. **Hooks NO se saltan**. Si pre-commit falla, fix raiz; si excede scope, ABORT (NO `--no-verify`).
|
||||
5. **Paths protegidos** de `dev/autonomous_protected_paths.json` se respetan exactamente.
|
||||
6. **Rama dedicada + worktree aislado SIEMPRE — sin excepciones**. Autopilot opera en `worktrees/auto-<NNNN>-<slug>/` (rama `auto/<NNNN>-<slug>`) para NO bloquear el working tree principal del humano. Aplica a issues, flows, registry-only y apps. Master NUNCA recibe commits directos en modo autopilot. Pre-flight obligatorio desde el working tree principal:
|
||||
```bash
|
||||
git fetch origin master
|
||||
git -C <main_repo> rev-parse --is-clean # tolerante: solo confirmar master rebased
|
||||
WT=worktrees/auto-<NNNN>-<slug>
|
||||
git worktree add -b auto/<NNNN>-<slug> "$WT" master
|
||||
cd "$WT" # todo el trabajo posterior aqui
|
||||
```
|
||||
Path del worktree:
|
||||
- **Dentro del repo**: `worktrees/auto-<NNNN>-<slug>/` (gitignored). Permite que herramientas con `FN_REGISTRY_ROOT` apunten al worktree y no a master.
|
||||
- Alternativa `/tmp/fn_autopilot_<NNNN>_<ts>/` si la app necesita aislamiento total del filesystem del repo (raro).
|
||||
Reanudacion idempotente: si el worktree ya existe -> `cd` a el + `git rebase master`. Si la rama `auto/<NNNN>-<slug>` existe pero el worktree no -> `git worktree add "$WT" auto/<NNNN>-<slug>`. Conflicto en rebase -> ABORT.
|
||||
Cierre exitoso: merge `--no-ff` a master desde el repo principal (`git -C <main> merge --no-ff auto/<NNNN>-<slug>`) solo tras tests verde + DoD 100%. Luego `git worktree remove <WT>` + `git branch -d auto/<NNNN>-<slug>`.
|
||||
Cierre fallido: worktree y rama quedan vivos para inspeccion humana. Master intacto.
|
||||
**Garantia**: el humano puede seguir editando en el repo principal mientras autopilot avanza — sin colisiones de checkout, sin index lockings, sin "uncommitted changes blocks branch switch".
|
||||
7. **Watchdog**: si la metrica de progreso (`acceptance_done / acceptance_total` para flows; `tests_pass / tests_total` o `checks_pass / checks_total` para issues) NO sube en 3 iteraciones consecutivas -> ABORT con `status=stalled`.
|
||||
8. **Timeout** default 60 min. Override con `--max-minutes`.
|
||||
9. **Idempotencia**: re-lanzar `/autopilot <target>` sobre el mismo target reanuda desde el ultimo `task_run` exitoso (lookup por `issue_id` o `flow_id` en `task_runs`).
|
||||
10. **No self-modification**: NUNCA tocar `.claude/agents/`, `.claude/commands/`, `.claude/rules/`, `.claude/scripts/`, `.claude/CLAUDE.md`.
|
||||
11. **Trazabilidad**: cada decision se persiste en `task_runs.events_json[]` con `{ts, agent, action, evidence, diff_summary, auto_choice, self_qa?}`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Q&A loop (corazon del modo)
|
||||
|
||||
Cuando aparece una decision sin Recomendado explicito, **NO abortar y NO preguntar al humano**. Claude:
|
||||
|
||||
1. **Formula la pregunta** en una frase. Una sola pregunta por bloque, especifica, contestable.
|
||||
2. **Lista opciones** (2-4). Misma forma que `AskUserQuestion` interno: `label + description`. Si solo hay una opcion viable, indicalo (`Options: [A] only viable`).
|
||||
3. **Razona en 1-3 lineas** apoyandote en: registry (`mcp__registry__fn_search`), reglas (`.claude/rules/`), tests previos, archivos del repo, convenciones del proyecto.
|
||||
4. **Elige** y marca `confidence: high|med|low`. Si `low` y la accion no es trivialmente reversible -> ABORT con `status=needs_human` y adjunta el bloque Q&A.
|
||||
5. **Persiste** en `events_json[]` con shape:
|
||||
```json
|
||||
{
|
||||
"ts": "...",
|
||||
"agent": "autopilot",
|
||||
"action": "self_qa",
|
||||
"self_qa": {
|
||||
"question": "Crear el flag enabled=false o ya enabled=true?",
|
||||
"options": [
|
||||
{"label": "enabled=false", "rationale": "TBD doctrina (feature_flags.md): merge codigo terminado pero NO expuesto"},
|
||||
{"label": "enabled=true", "rationale": "feature ya tiene tests verde y DoD 100%"}
|
||||
],
|
||||
"choice": "enabled=false",
|
||||
"confidence": "high",
|
||||
"reasoning": "feature_flags.md regla: 'cuando se activa: cambiar enabled:true y rellenar enabled_at'. Activar va en commit posterior."
|
||||
}
|
||||
}
|
||||
```
|
||||
6. **Avanza** sin esperar.
|
||||
|
||||
**Tope de self-Q&A**: `--max-self-answers` (default 20). Si se excede -> ABORT `status=overdeliberating` con dump de todas las Q&A. Una iteracion del bucle que necesita >5 Q&A es señal de DoD vago — abortar.
|
||||
|
||||
**Cuando NO usar self-Q&A (ABORT en vez de auto-responder)**:
|
||||
|
||||
| Caso | Razon |
|
||||
|---|---|
|
||||
| Destructivo sin rollback (`git reset --hard`, `rm -rf` fuera `/tmp/`, `DROP TABLE`, `--no-verify`, `--force`) | Coste de error infinito |
|
||||
| Credenciales/tokens/secrets | Riesgo de exfiltracion |
|
||||
| Paths protegidos (`dev/autonomous_protected_paths.json`) | Regla dura del orquestador |
|
||||
| Contradiccion explicita con DoD del issue | DoD es contrato |
|
||||
| Decision arquitectonica multi-app (renombrar tabla compartida, romper API publica) | Blast radius > 1 artefacto |
|
||||
| `confidence: low` + accion no reversible | Self-Q&A no garantiza acierto sin oraculo |
|
||||
|
||||
---
|
||||
|
||||
## Dispatch logic
|
||||
|
||||
### Path A: issue compatible con `fn-orquestador`
|
||||
|
||||
Si el issue declara o se infiere tipo en `(feature_app_simple, bugfix_with_repro, refactor_safe, add_e2e_check)` Y toca apps/modules/framework:
|
||||
|
||||
- Delega a `fn-orquestador` via `Agent(subagent_type="fn-orquestador", ...)` pasando:
|
||||
- `issue_id`, `--auto-apply-proposals safe`, `--max-iterations`, `--max-minutes`, paths protegidos.
|
||||
- Espera resultado, reenvia `task_run_id` + PR draft URL al humano.
|
||||
|
||||
### Path B: issue registry-only (functions/types/docs/rules)
|
||||
|
||||
- **Rama + worktree `worktrees/auto-<NNNN>-<slug>/`** desde master actualizado (regla dura 6). Humano sigue trabajando en el repo principal en paralelo.
|
||||
- Politica `apps_tbd.md` permite push directo a master para registry-only en modo humano, pero `/autopilot` NO lo usa: el aislamiento por rama+worktree es la garantia de rollback + paralelismo en modo autonomo.
|
||||
- Plan inline con TaskCreate:
|
||||
1. Pre-flight worktree (`git fetch` + `git worktree add -b auto/<NNNN>-<slug> worktrees/auto-<NNNN>-<slug> master` + `cd` a el).
|
||||
2. Leer issue + extraer DoD.
|
||||
3. Search registry para piezas existentes (registry-first).
|
||||
4. Si falta funcion -> spawn `fn-constructor` paralelo.
|
||||
5. `fn index`.
|
||||
6. Tests (`go test`, `pytest`, `bash -n`, segun stack).
|
||||
7. Si toco modulos/framework -> `/version` correspondiente.
|
||||
8. Mover `dev/issues/<NNNN>-*.md` a `dev/issues/completed/`.
|
||||
9. Actualizar `dev/issues/README.md` (si existe).
|
||||
10. Commit atomico por bloque logico en la rama.
|
||||
11. Solo si TODOS los tests pasan + DoD 100%: merge `--no-ff` a master + push + delete rama. Si algo falla -> rama queda viva, master intacto.
|
||||
- Verificacion final: DoD checkboxes -> todos marcados.
|
||||
|
||||
### Path C: flow
|
||||
|
||||
Runner inline (fase 2 manual, mientras `/flow run` no exista):
|
||||
|
||||
1. **Rama + worktree `worktrees/auto-flow-<NNNN>-<slug>/`** desde master (regla dura 6) — incluso para flows que solo ejecutan funciones sin escribir codigo, asi los side-effects en `dev/flows/<NNNN>-*.md` (checkboxes) y `dev/flows/runs/*.jsonl` se commitean en rama, no en master. Humano puede seguir editando el repo principal en paralelo.
|
||||
2. Parsea `## Flow` del .md.
|
||||
3. Cada paso tipo `function: <id>` -> `./fn run <id> [args]`.
|
||||
4. Cada paso tipo `cmd: <bash>` -> Bash tool (con guardas destructivas).
|
||||
5. Paso "MANUAL: ..." -> si tiene equivalente automatico (ej. "abrir Chrome y loguearse" vs `cdp_extract_recipe`) usa el automatico; si no tiene equivalente -> ABORT con `status=needs_human` y razon.
|
||||
6. Tras cada paso, evalua `## Acceptance` checkboxes via heuristicas:
|
||||
- "X runs en data_factory" -> `sqlite3` count.
|
||||
- "DAG corre 2 veces consecutivas" -> consultar `dag_engine` logs.
|
||||
- Otros -> dejar como `[ ]` y reportar.
|
||||
7. Persiste run en `dev/flows/runs/<NNNN>-<ts>.jsonl`.
|
||||
8. Si todos `[ ]` -> `[x]` -> commit en rama + merge `--no-ff` a master + `/flow done <NNNN>` + delete rama.
|
||||
|
||||
---
|
||||
|
||||
## Output canonico
|
||||
|
||||
```
|
||||
=== /autopilot 0107c ===
|
||||
target: issue 0107c (refactor data_table)
|
||||
path: B (registry-only)
|
||||
status: done
|
||||
iterations: 3 / 10
|
||||
duration: 18 min / 60
|
||||
dod_checks: 5/5 pass
|
||||
proposals: 2 creadas, 1 auto-aplicada
|
||||
self_qa: 7 (6 high / 1 med / 0 low)
|
||||
agents_spawned: fn-constructor x2, fn-recopilador x1
|
||||
commits: 4 (3 feat + 1 refactor)
|
||||
branch: master (registry-only, push directo)
|
||||
|
||||
Trace:
|
||||
1. construir → ok (2 funciones nuevas, 1 split)
|
||||
2. tests → ok (43/43)
|
||||
3. version → /version modules/data_table major "..."
|
||||
4. close → mv to completed/, push
|
||||
|
||||
Siguiente: ningun paso humano requerido. Verificar con: fn doctor modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sub-comando: `/autopilot check <target>`
|
||||
|
||||
Solo audita readiness — **no** ejecuta nada.
|
||||
Si falla:
|
||||
|
||||
```
|
||||
=== /autopilot check 0125 ===
|
||||
@@ -240,12 +70,10 @@ status: NOT READY
|
||||
target: issue 0125 (skill-tree-dashboard-panel)
|
||||
gaps:
|
||||
- Sin seccion DoD/Acceptance
|
||||
- Frontmatter sin priority
|
||||
non_verifiable_criteria:
|
||||
- "UX intuitiva" (linea 47)
|
||||
- "UX intuitiva" linea 47 — no verificable
|
||||
fix:
|
||||
- Anadir ## DoD con 3-5 bullets programaticamente verificables
|
||||
- Reemplazar "UX intuitiva" por criterio medible
|
||||
- Reemplazar criterios subjetivos por mediciones concretas
|
||||
```
|
||||
|
||||
Si OK:
|
||||
@@ -255,86 +83,130 @@ Si OK:
|
||||
status: READY
|
||||
target: issue 0107c (refactor data_table)
|
||||
dod_items: 5 checkboxes
|
||||
path_inferred: B (registry-only — modules/)
|
||||
task_type: refactor_safe
|
||||
estimated_iter: 3-5
|
||||
```
|
||||
|
||||
---
|
||||
## Dispatch a fn-orquestador
|
||||
|
||||
Tras pre-flight OK, ejecuta:
|
||||
|
||||
```
|
||||
Agent(
|
||||
subagent_type="fn-orquestador",
|
||||
prompt="""
|
||||
Issue/Flow: <path al .md>
|
||||
Modo: REAL (o --dry-run)
|
||||
task_type: <issue|flow>
|
||||
Pre-condiciones verificadas: 7/7 verde
|
||||
Master: <sha> sync con origin
|
||||
Working tree principal: limpio (baseline)
|
||||
Max iter: N
|
||||
Max min: M
|
||||
Auto-apply proposals: safe
|
||||
Token Gitea: pass gitea/dataforge-git-token
|
||||
DB task_runs: apps/deploy_server/operations.db (schema task_id)
|
||||
Reglas duras: autonomous_loop.md (11 reglas)
|
||||
""",
|
||||
run_in_background=true
|
||||
)
|
||||
```
|
||||
|
||||
Cuando termine, reporta al humano con output canonico del orquestador:
|
||||
|
||||
```
|
||||
=== /autopilot 0121b ===
|
||||
target: issue 0121b (fn doctor e2e-coverage)
|
||||
delegated_to: fn-orquestador
|
||||
status: converged
|
||||
iterations: 1 / 8
|
||||
duration: 4 min / 30
|
||||
task_run_id: task_d285372493cce2e6
|
||||
branch: auto/0121b-orquestador
|
||||
worktree: /tmp/fn_orq_0121b_1779147778
|
||||
PR draft: https://gitea-.../dataforge/fn_registry/pulls/3
|
||||
|
||||
Siguiente: revisar PR, mergear, mover issue a completed/
|
||||
```
|
||||
|
||||
## Reglas duras (autopilot-level)
|
||||
|
||||
1. **Cero cwd mutation**. Autopilot NUNCA hace `cd`. Usa `git -C <repo>` siempre si necesita inspeccionar.
|
||||
2. **Cero ejecucion inline de bucle**. Todo va via `fn-orquestador`. Si autopilot necesita ejecutar algo (pre-flight scripts), es read-only.
|
||||
3. **Cero AskUserQuestion**. Self-pick "Recommended". Si no hay, ABORT con `status=needs_human`.
|
||||
4. **DoD es contrato**. Si DoD no se cumple al final, `task_run.status` queda `partial` y autopilot reporta NOT_DONE — humano decide.
|
||||
5. **Worktree gestion delegada al orquestador**. Autopilot NO crea worktrees propios. NO toca branches.
|
||||
6. **Trazabilidad**: cada decision pre-delegate (especialmente abort de DoD check) se persiste en `task_runs.events_json[]` con `agent: autopilot`.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Que hace |
|
||||
|---|---|---|
|
||||
| `--max-iterations N` | 10 | Tope de iteraciones del bucle |
|
||||
| `--max-minutes M` | 60 | Timeout total |
|
||||
| `--dry-run` | off | Plan + dispatch simulado, no aplica cambios |
|
||||
| `--allow-construct-missing` | off | Si flow tiene `FALTA: crear <id>`, spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado a fn-orquestador en Path A |
|
||||
| `--max-self-answers N` | 20 | Tope de bloques Self-Q&A por run. Excedido -> ABORT `overdeliberating` |
|
||||
|
||||
---
|
||||
| `--max-iterations N` | 10 | Pasado al orquestador |
|
||||
| `--max-minutes M` | 60 | Pasado al orquestador |
|
||||
| `--dry-run` | off | Pasado al orquestador |
|
||||
| `--allow-construct-missing` | off | Flow con `FALTA: crear <id>` → spawn fn-constructor antes |
|
||||
| `--auto-apply-proposals` | `safe` | Pasado al orquestador |
|
||||
|
||||
## Errores canonicos
|
||||
|
||||
| Codigo | Significado | Accion |
|
||||
|---|---|---|
|
||||
| `NOT_READY` | DoD insuficiente | Humano edita .md y relanza |
|
||||
| `needs_human` | Decision sin Recomendado | Humano resuelve y relanza |
|
||||
| `stalled` | 3 iteraciones sin progreso | Humano revisa `events_json` |
|
||||
| `timeout` | Excedido `--max-minutes` | Aumentar timeout o partir issue |
|
||||
| `aborted_protected_path` | Cambio en path protegido | Humano revisa intent |
|
||||
| `iterations_exhausted` | Excedido `--max-iterations` | Humano evalua si vale subir tope |
|
||||
| `sandbox_breach` | Diff fuera del worktree | ABORT critico, audit |
|
||||
| `overdeliberating` | Excedido `--max-self-answers` | DoD probablemente vago — humano refina criterios |
|
||||
| `low_confidence_abort` | Self-Q&A devolvio `confidence: low` en accion no reversible | Humano valida la decision concreta |
|
||||
|
||||
---
|
||||
| `needs_human` | Decision ambigua | Humano resuelve y relanza |
|
||||
| `delegated_failed` | fn-orquestador devolvio fail/stall/timeout | Humano lee `task_runs.events_json` |
|
||||
| (resto) | Heredados del orquestador (stalled/timeout/aborted_protected_path/...) | Idem |
|
||||
|
||||
## Anti-patrones
|
||||
|
||||
| Anti-patron | Por que es malo |
|
||||
|---|---|
|
||||
| `/autopilot` sin pre-check DoD | Trabajar sin criterio de exito = bucle infinito |
|
||||
| Auto-relleno de DoD inventada | Criterios falsos -> falso "done" |
|
||||
| Merge a master sin tests verde | Master no deployable |
|
||||
| `AskUserQuestion` al humano | Rompe el contrato autonomo — usa self-Q&A loop |
|
||||
| Self-Q&A sin razonamiento explicito | Decision opaca, no auditable |
|
||||
| Self-Q&A con `confidence: high` en accion destructiva sin oraculo | Confianza injustificada — escalar |
|
||||
| Salto de hooks (`--no-verify`) | Encubre bugs reales |
|
||||
| Tocar mas issues que el target | Scope creep silencioso |
|
||||
| Borrar archivos sin backup en events_json | Pierde auditoria |
|
||||
|
||||
---
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog).
|
||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||
- [[registry_calls]] — invocaciones canonicas (MCP / fn run / heredoc).
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador como gate de Path A.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
---
|
||||
| Hacer Path B/C inline | Mismo bug de cwd mutation que paso 2026-05-19 |
|
||||
| Saltar pre-flight DoD | Trabajar sin contrato = bucle infinito |
|
||||
| Mergear sin tests verde | fn-orquestador ya impide esto, NO bypaseas |
|
||||
| `AskUserQuestion` desde autopilot | Rompe contrato autonomo |
|
||||
| Crear worktree propio en autopilot | Duplica + colision con orquestador (paso 2026-05-19) |
|
||||
|
||||
## Ejemplos
|
||||
|
||||
```bash
|
||||
# Issue registry-only con DoD claro
|
||||
# Issue con DoD claro
|
||||
/autopilot 0107c
|
||||
/autopilot i:0107c # equivalente con prefijo explicito
|
||||
|
||||
# Issue app que requiere orquestador
|
||||
/autopilot issue:0070 --max-iterations 15 --max-minutes 90
|
||||
|
||||
# Flow con piezas faltantes — autoriza creacion antes
|
||||
/autopilot flow:0008 --allow-construct-missing
|
||||
|
||||
# Solo audit, no ejecutar
|
||||
# Solo audit
|
||||
/autopilot check 0125
|
||||
/autopilot check flow:0008
|
||||
|
||||
# Dry run
|
||||
/autopilot 0107c --dry-run
|
||||
```
|
||||
|
||||
## Relacion con otras reglas
|
||||
|
||||
- [[autonomous_loop]] — politica del bucle (sandbox, paths protegidos, watchdog). fn-orquestador la aplica.
|
||||
- [[apps_tbd]] — politica TBD por tipo de cambio.
|
||||
- [[apps_subrepo]] — `git init` dentro de apps nuevas antes de limpiar worktree.
|
||||
- [[feature_flags]] — codigo incompleto detras de flag OFF.
|
||||
- [[registry_calls]] — invocaciones canonicas.
|
||||
- [[e2e_validation]] — `e2e_checks` consumidos por fn-analizador.
|
||||
- [[delegation]] — spawn fn-constructor antes que escribir inline.
|
||||
|
||||
## Migracion desde `/autonomous-task`
|
||||
|
||||
`/autonomous-task` queda DEPRECADO. Sustitucion 1:1:
|
||||
|
||||
| Antes | Ahora |
|
||||
|---|---|
|
||||
| `/autonomous-task 0070` | `/autopilot 0070` |
|
||||
| `/autonomous-task 0070 --max-iterations 15` | `/autopilot 0070 --max-iterations 15` |
|
||||
| `/autonomous-task 0070 --dry-run` | `/autopilot 0070 --dry-run` |
|
||||
|
||||
`/autopilot` anade pre-flight DoD check + detect flow. Behaviour orquestador-side idem.
|
||||
|
||||
## Historico
|
||||
|
||||
- v1 (2026-05-15): introducido con Path A/B/C inline + self-Q&A.
|
||||
- v2 (2026-05-19): simplificado tras incidente cwd mutation en piloto 0121b. Solo delega a fn-orquestador. Self-Q&A movido al orquestador. Sustituye a `/autonomous-task`.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
description: "Lista todos los slash commands disponibles en el repo: globales de fn_registry + namespaced de cada project. Filtra por substring o por namespace."
|
||||
---
|
||||
|
||||
# /commands — Catalogo de slash commands del repo
|
||||
|
||||
Inventario unificado. Lista los `.md` bajo `.claude/commands/` (recursivo, sigue symlinks) y agrupa por namespace.
|
||||
|
||||
## Sintaxis
|
||||
|
||||
```
|
||||
/commands # listado completo agrupado por namespace
|
||||
/commands <substring> # filtra por substring en nombre o descripcion
|
||||
/commands --ns <namespace> # solo un namespace (global, aurgi, ...)
|
||||
/commands --json # salida JSON para agentes
|
||||
```
|
||||
|
||||
## Implementacion
|
||||
|
||||
Bash + awk. Parsea frontmatter `description:` de cada `.md`. Agrupa por subdirectorio (subdir = namespace, root = `global`).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="${FN_REGISTRY_ROOT:-/home/egutierrez/fn_registry}"
|
||||
CMD_DIR="$ROOT/.claude/commands"
|
||||
|
||||
# Recolecta: ns|name|description
|
||||
collect() {
|
||||
find -L "$CMD_DIR" -type f -name '*.md' | while read -r f; do
|
||||
rel="${f#$CMD_DIR/}"
|
||||
case "$rel" in
|
||||
*/*) ns="${rel%%/*}"; name="${rel#*/}"; name="${name%.md}" ;;
|
||||
*) ns="global"; name="${rel%.md}" ;;
|
||||
esac
|
||||
desc=$(awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$f")
|
||||
printf '%s|%s|%s\n' "$ns" "$name" "${desc:-(sin descripcion)}"
|
||||
done | sort
|
||||
}
|
||||
|
||||
collect | awk -F'|' '
|
||||
{
|
||||
if ($1 != prev_ns) {
|
||||
if (prev_ns) print ""
|
||||
if ($1 == "global") print "## global (/<cmd>)"
|
||||
else print "## " $1 " (/" $1 ":<cmd>)"
|
||||
prev_ns = $1
|
||||
}
|
||||
printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3
|
||||
}'
|
||||
```
|
||||
|
||||
Filtros:
|
||||
|
||||
- Substring: `grep -i "<substring>"` sobre stdout.
|
||||
- `--ns X`: filtrar antes del `awk` por `$1 == "X"`.
|
||||
- `--json`: reemplazar el `awk` por `jq -Rsn` que construya array `{namespace, name, description, invocation}`.
|
||||
|
||||
## Salida (formato humano)
|
||||
|
||||
```
|
||||
## global (/<cmd>)
|
||||
- /app — Crear, configurar y desplegar apps del registry
|
||||
- /autopilot — Modo full-auto...
|
||||
- /commands — Catalogo de slash commands del repo
|
||||
...
|
||||
|
||||
## aurgi (/aurgi:<cmd>)
|
||||
- /aurgi:anadir_contexto_aurgi — Anade o modifica contexto...
|
||||
- /aurgi:aumentar_task — Enriquece tarea Aurgi con preguntas...
|
||||
- /aurgi:contexto_aurgi — Aprende el contexto de Aurgi...
|
||||
```
|
||||
|
||||
## Cuando usarlo
|
||||
|
||||
- Sesion nueva: ver de un vistazo que slash commands hay disponibles.
|
||||
- Antes de inventar logica inline: comprobar si ya existe un command.
|
||||
- Auditoria: verificar que los projects exponen sus commands correctamente.
|
||||
- Onboarding: nuevo PC clonado, descubrir capacidades del repo sin abrir N archivos.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Sigue symlinks (`find -L`). Si un symlink apunta a directorio inexistente, devuelve vacio para esa rama — verificar con `ls -L .claude/commands/<ns>/`.
|
||||
- Solo escanea `<root>/.claude/commands/`. Commands user-global en `~/.claude/commands/` NO entran (son personales, fuera del repo).
|
||||
- Namespace = nombre del subdirectorio bajo `.claude/commands/`. Coincide con el project pero no por mecanismo — por convencion. Ver `.claude/rules/project_commands.md`.
|
||||
- Para que un command de project aparezca aqui desde la raiz, hace falta el symlink (`.claude/commands/<project>` -> `../../projects/<project>/.claude/commands`).
|
||||
@@ -37,3 +37,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente.
|
||||
| 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 |
|
||||
| 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 |
|
||||
| 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 |
|
||||
| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
## Slash commands por project (namespaced)
|
||||
|
||||
Cada `projects/<p>/` puede tener su propio `.claude/commands/*.md`. Para invocarlos desde la raiz de `fn_registry` sin que pisen los comandos globales, se exponen via **symlink namespaced** en `fn_registry/.claude/commands/<project>/`.
|
||||
|
||||
### Patron canonico
|
||||
|
||||
```
|
||||
projects/aurgi/.claude/commands/foo.md # archivo real (viaja con el sub-repo del project)
|
||||
fn_registry/.claude/commands/aurgi -> symlink -> ../../projects/aurgi/.claude/commands
|
||||
```
|
||||
|
||||
Resultado:
|
||||
|
||||
| cwd | Invocacion |
|
||||
|---|---|
|
||||
| `cd projects/aurgi && claude` | `/foo` (sin namespace) |
|
||||
| `cd fn_registry && claude` | `/aurgi:foo` (namespaced, no colisiona con `/foo` global) |
|
||||
|
||||
Subdirs dentro de `.claude/commands/` se exponen como namespace en el slash command. Por eso `aurgi/foo.md` -> `/aurgi:foo`.
|
||||
|
||||
### Como anadir un project nuevo
|
||||
|
||||
1. `mkdir -p projects/<p>/.claude/commands/`.
|
||||
2. Crear `<comando>.md` con frontmatter `description:` + cuerpo.
|
||||
3. Symlink: `ln -sf ../../projects/<p>/.claude/commands /home/egutierrez/fn_registry/.claude/commands/<p>`.
|
||||
4. Versionar el `.claude/commands/` del project en su propio sub-repo (NO en fn_registry — projects estan gitignored).
|
||||
5. Versionar SOLO el symlink en fn_registry (`git add .claude/commands/<p>`).
|
||||
|
||||
### Reglas
|
||||
|
||||
- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/<p>` como desde la raiz.
|
||||
- El symlink en fn_registry da acceso global con namespace — sin colision con commands del registry.
|
||||
- NO duplicar contenido: archivo real solo en `projects/<p>/.claude/commands/`. fn_registry solo guarda el symlink.
|
||||
- Si el project se mueve/elimina, borrar el symlink en fn_registry.
|
||||
|
||||
### Listado actual
|
||||
|
||||
| Project | Symlink | Commands disponibles desde fn_registry |
|
||||
|---|---|---|
|
||||
| aurgi | `.claude/commands/aurgi` | `/aurgi:aumentar_task`, `/aurgi:contexto_aurgi`, `/aurgi:anadir_contexto_aurgi` |
|
||||
|
||||
Anadir filas aqui al introducir un project nuevo con commands.
|
||||
|
||||
### Catalogo dinamico
|
||||
|
||||
Para listado en tiempo real (sin tener que actualizar esta tabla a mano): `/commands` escanea `.claude/commands/` recursivo y agrupa por namespace. Filtros: `/commands <substring>`, `/commands --ns <ns>`, `/commands --json`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- Claude Code lista los commands disponibles al inicio de sesion. Si un symlink apunta a un directorio inexistente, los commands no aparecen — verificar con `ls -L .claude/commands/<project>/`.
|
||||
- El namespace usa el nombre del subdirectorio (`aurgi/`), no del project en `projects/`. Mantenerlos iguales para evitar confusion.
|
||||
- Los commands del project se ejecutan con el cwd de la sesion actual. Un `/aurgi:aumentar_task` invocado desde `fn_registry/` corre con cwd `fn_registry/` — paths relativos en el `.md` deben asumir esto (siempre usar paths relativos al repo, ej. `projects/aurgi/vaults/...`).
|
||||
+4
-4
@@ -530,8 +530,8 @@ if(EXISTS ${_PROCESS_EXPLORER_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_PROCESS_EXPLORER_DIR} ${CMAKE_BINARY_DIR}/apps/process_explorer)
|
||||
endif()
|
||||
|
||||
# --- kanban_cpp (lives in apps/, issue 0096) ---
|
||||
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
|
||||
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_KANBAN_CPP_DIR} ${CMAKE_BINARY_DIR}/apps/kanban_cpp)
|
||||
# --- agents_dashboard (lives in projects/element_agents/apps/) ---
|
||||
set(_AGENTS_DASHBOARD_DIR ${CMAKE_SOURCE_DIR}/../projects/element_agents/apps/agents_dashboard)
|
||||
if(EXISTS ${_AGENTS_DASHBOARD_DIR}/CMakeLists.txt)
|
||||
add_subdirectory(${_AGENTS_DASHBOARD_DIR} ${CMAKE_BINARY_DIR}/apps/agents_dashboard)
|
||||
endif()
|
||||
|
||||
Submodule
+1
Submodule cpp/apps/chart_demo added at 026f514bb7
Submodule
+1
Submodule cpp/apps/shaders_lab added at dc9a970aff
@@ -0,0 +1,167 @@
|
||||
// secret_store.cpp — implementation of fn_secret (issue 0129).
|
||||
//
|
||||
// See secret_store.h for API docs and platform notes.
|
||||
|
||||
#include "infra/secret_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _WIN32
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# include <windows.h>
|
||||
# include <wincrypt.h>
|
||||
# pragma comment(lib, "crypt32.lib")
|
||||
#endif
|
||||
|
||||
namespace fn_secret {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 helpers (no external deps, RFC 4648 alphabet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const char kB64Chars[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
static std::string base64_encode(const uint8_t* data, size_t len) {
|
||||
std::string out;
|
||||
out.reserve(((len + 2) / 3) * 4);
|
||||
for (size_t i = 0; i < len; i += 3) {
|
||||
uint32_t b = (uint32_t)data[i] << 16;
|
||||
if (i + 1 < len) b |= (uint32_t)data[i + 1] << 8;
|
||||
if (i + 2 < len) b |= (uint32_t)data[i + 2];
|
||||
out += kB64Chars[(b >> 18) & 63];
|
||||
out += kB64Chars[(b >> 12) & 63];
|
||||
out += (i + 1 < len) ? kB64Chars[(b >> 6) & 63] : '=';
|
||||
out += (i + 2 < len) ? kB64Chars[(b) & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> base64_decode(const std::string& s) {
|
||||
auto decode_char = [](char c) -> int {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
};
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(s.size() * 3 / 4);
|
||||
for (size_t i = 0; i + 3 < s.size(); i += 4) {
|
||||
int a = decode_char(s[i]);
|
||||
int b = decode_char(s[i + 1]);
|
||||
int c = decode_char(s[i + 2]);
|
||||
int d = decode_char(s[i + 3]);
|
||||
if (a < 0 || b < 0) break;
|
||||
out.push_back((uint8_t)((a << 2) | (b >> 4)));
|
||||
if (c >= 0) out.push_back((uint8_t)((b << 4) | (c >> 2)));
|
||||
if (d >= 0) out.push_back((uint8_t)((c << 2) | d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linux fallback: XOR with a stable per-user key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#ifndef _WIN32
|
||||
static std::vector<uint8_t> linux_key() {
|
||||
// Key = first 32 bytes of SHA-256-like mixing of LOGNAME + HOSTNAME.
|
||||
// Good enough to prevent casual plaintext inspection; NOT crypto-secure.
|
||||
const char* user = getenv("LOGNAME");
|
||||
const char* host = getenv("HOSTNAME");
|
||||
if (!user) user = "user";
|
||||
if (!host) host = "localhost";
|
||||
std::string seed = std::string(user) + "@" + host + ":fn_agents_dashboard_key_v1";
|
||||
std::vector<uint8_t> key(32, 0);
|
||||
for (size_t i = 0; i < seed.size(); i++) {
|
||||
key[i % 32] ^= (uint8_t)seed[i];
|
||||
key[(i + 7) % 32] += (uint8_t)(seed[i] * 31 + i);
|
||||
key[(i + 13) % 32] ^= (uint8_t)(seed[i] + i * 7);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool is_strong() {
|
||||
#ifdef _WIN32
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encrypt(const std::string& plaintext) {
|
||||
#ifdef _WIN32
|
||||
DATA_BLOB in_blob { (DWORD)plaintext.size(),
|
||||
(BYTE*)const_cast<char*>(plaintext.data()) };
|
||||
DATA_BLOB out_blob {};
|
||||
if (!CryptProtectData(&in_blob, L"fn_agents_dashboard", nullptr,
|
||||
nullptr, nullptr, 0, &out_blob)) {
|
||||
return {};
|
||||
}
|
||||
std::vector<uint8_t> result(out_blob.pbData,
|
||||
out_blob.pbData + out_blob.cbData);
|
||||
LocalFree(out_blob.pbData);
|
||||
return result;
|
||||
#else
|
||||
// Linux: 1-byte magic + XOR
|
||||
std::vector<uint8_t> key = linux_key();
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(1 + plaintext.size());
|
||||
out.push_back(0xAF); // magic marker
|
||||
for (size_t i = 0; i < plaintext.size(); i++) {
|
||||
out.push_back((uint8_t)plaintext[i] ^ key[i % key.size()]);
|
||||
}
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string decrypt(const std::vector<uint8_t>& blob) {
|
||||
if (blob.empty()) return {};
|
||||
#ifdef _WIN32
|
||||
DATA_BLOB in_blob { (DWORD)blob.size(),
|
||||
(BYTE*)const_cast<uint8_t*>(blob.data()) };
|
||||
DATA_BLOB out_blob {};
|
||||
if (!CryptUnprotectData(&in_blob, nullptr, nullptr,
|
||||
nullptr, nullptr, 0, &out_blob)) {
|
||||
return {};
|
||||
}
|
||||
std::string result(reinterpret_cast<char*>(out_blob.pbData),
|
||||
out_blob.cbData);
|
||||
LocalFree(out_blob.pbData);
|
||||
return result;
|
||||
#else
|
||||
// Linux: check magic, XOR decode
|
||||
if (blob[0] != 0xAF) return {};
|
||||
std::vector<uint8_t> key = linux_key();
|
||||
std::string out;
|
||||
out.reserve(blob.size() - 1);
|
||||
for (size_t i = 1; i < blob.size(); i++) {
|
||||
out += (char)(blob[i] ^ key[(i - 1) % key.size()]);
|
||||
}
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string encrypt_b64(const std::string& plaintext) {
|
||||
auto blob = encrypt(plaintext);
|
||||
if (blob.empty()) return {};
|
||||
return base64_encode(blob.data(), blob.size());
|
||||
}
|
||||
|
||||
std::string decrypt_b64(const std::string& b64) {
|
||||
auto blob = base64_decode(b64);
|
||||
return decrypt(blob);
|
||||
}
|
||||
|
||||
} // namespace fn_secret
|
||||
@@ -0,0 +1,37 @@
|
||||
// secret_store.h — encrypt/decrypt sensitive strings for local storage.
|
||||
//
|
||||
// Windows: uses DPAPI (CryptProtectData / CryptUnprotectData).
|
||||
// The encrypted blob is bound to the current user account on the local
|
||||
// machine. Key never leaves the machine. The blob can be stored in
|
||||
// SQLite as a BLOB column.
|
||||
//
|
||||
// Linux/WSL fallback: XOR-encode with a stable per-user key derived from
|
||||
// username + hostname. NOT cryptographically strong — but prevents
|
||||
// plaintext credentials sitting in SQLite and shows a warning in the UI.
|
||||
// Production use should switch to libsecret / KDE Wallet on Linux.
|
||||
//
|
||||
// Part of issue 0129 (agents_dashboard credential storage).
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_secret {
|
||||
|
||||
// Encrypt `plaintext` into an opaque blob suitable for storage in a BLOB column.
|
||||
// Returns empty vector on failure; never throws.
|
||||
std::vector<uint8_t> encrypt(const std::string& plaintext);
|
||||
|
||||
// Decrypt a blob produced by `encrypt()`.
|
||||
// Returns empty string on failure (wrong key, corrupted data, etc.).
|
||||
std::string decrypt(const std::vector<uint8_t>& blob);
|
||||
|
||||
// Convenience: encrypt returns base64 string for TEXT storage.
|
||||
std::string encrypt_b64(const std::string& plaintext);
|
||||
std::string decrypt_b64(const std::string& b64);
|
||||
|
||||
// Returns true if running with strong DPAPI encryption (Windows).
|
||||
// Returns false on Linux fallback — callers may show a warning.
|
||||
bool is_strong();
|
||||
|
||||
} // namespace fn_secret
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: secret_store
|
||||
kind: function
|
||||
lang: cpp
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "fn_secret::encrypt(plaintext) -> vector<uint8_t>; fn_secret::decrypt(blob) -> string; fn_secret::is_strong() -> bool"
|
||||
description: "Encrypt/decrypt sensitive strings for local SQLite storage. Windows: DPAPI (user-bound, machine-local, cryptographically strong). Linux/WSL fallback: XOR with per-user seed key (not crypto-secure, shows warning). Used by agents_dashboard to store API keys."
|
||||
tags: [security, credentials, dpapi, encrypt, infra, agents]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [infra/secret_store.h]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "cpp/functions/infra/secret_store.cpp"
|
||||
framework: ""
|
||||
params:
|
||||
- name: plaintext
|
||||
desc: "Sensitive string to encrypt (API key, password, token)."
|
||||
- name: blob
|
||||
desc: "Opaque byte vector returned by encrypt(), stored as SQLite BLOB column."
|
||||
output: "encrypt returns vector<uint8_t> blob (empty on failure). decrypt returns plaintext string (empty on failure). is_strong() returns true on Windows (DPAPI), false on Linux (XOR fallback)."
|
||||
---
|
||||
|
||||
# secret_store
|
||||
|
||||
Encrypt/decrypt sensitive credentials for local SQLite storage.
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```cpp
|
||||
#include "infra/secret_store.h"
|
||||
|
||||
// Store API key encrypted:
|
||||
std::vector<uint8_t> blob = fn_secret::encrypt("my-api-key-here");
|
||||
// Insert blob into SQLite BLOB column via sqlite3_bind_blob()...
|
||||
|
||||
// Recover:
|
||||
std::string key = fn_secret::decrypt(blob);
|
||||
|
||||
// Base64 helpers for TEXT columns:
|
||||
std::string b64 = fn_secret::encrypt_b64("my-api-key-here");
|
||||
std::string back = fn_secret::decrypt_b64(b64);
|
||||
|
||||
// Platform check (show warning on Linux):
|
||||
if (!fn_secret::is_strong()) {
|
||||
fn_log::warn("[security] apikey stored with weak Linux fallback encryption");
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de guardar una API key, token o contrasena en SQLite local. Siempre usar `fn::local_path("app.db")` para la DB. En Windows (DPAPI) la clave nunca sale de la maquina. En Linux, mostrar aviso en UI de que la proteccion es basica.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **DPAPI is Windows-only**: el blob cifrado en Windows NO se puede descifrar en Linux y viceversa. Si el usuario mueve la DB entre plataformas, las credenciales se pierden — debe reingresar la apikey.
|
||||
- **Linux fallback NO es criptograficamente seguro**: XOR con semilla derivada de username+hostname. Previene lectura casual pero no protege contra atacante con acceso al sistema.
|
||||
- **CryptProtectData es sincrono**: no llamar desde el thread principal con datos grandes. Para una apikey (tipicamente <200 bytes) el coste es despreciable.
|
||||
- Linkear `crypt32.lib` en Windows: el `.cpp` tiene `#pragma comment(lib, "crypt32.lib")` — no necesita entry en CMakeLists para MSVC. Con MinGW se enlaza automaticamente si se incluye `wincrypt.h`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: "0123"
|
||||
title: "/flow run + fn-meta-orquestador: ejecutar flows + paralelo issues autonomos"
|
||||
title: "fn-meta-orquestador + fn-priorizador + fn doctor issues/flows"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
@@ -15,10 +15,13 @@ related:
|
||||
- "0069"
|
||||
- "0102"
|
||||
created: 2026-05-18
|
||||
updated: 2026-05-18
|
||||
tags: [flows, runner, meta-orquestador, paralelo, priorizador]
|
||||
updated: 2026-05-19
|
||||
tags: [meta-orquestador, paralelo, priorizador, doctor]
|
||||
---
|
||||
|
||||
**REVISION 2026-05-19:** `/flow run` y `/fix-flow` se ELIMINAN del scope. Absorbidos por `/autopilot` v2 (delega a fn-orquestador que ganara modo `task_type=flow`). Issue queda con 3 piezas: meta-orquestador + priorizador + doctor.
|
||||
|
||||
|
||||
# 0123 — Flows ejecutables + meta-orquestador paralelo
|
||||
|
||||
## Problema
|
||||
@@ -27,29 +30,32 @@ tags: [flows, runner, meta-orquestador, paralelo, priorizador]
|
||||
2. `parallel-fix-issues` lanza N agentes Claude vanilla en worktrees. `fn-orquestador` lanza 1 issue autonomo en worktree. NO existe combinacion: N issues autonomos coordinados respetando dep-graph.
|
||||
3. `/work today` prioriza con regla fija (prio+deps+DoD%). NO usa errores e2e, blast radius ni huerfanas para reordenar.
|
||||
|
||||
## Decision
|
||||
## Decision (revision 2026-05-19)
|
||||
|
||||
Tres piezas:
|
||||
|
||||
1. **`/flow run <NNNN>`**: ejecuta Acceptance checkboxes como steps. Cada step = `./fn run <id>` o subagent call. Logea en `data_factory.runs` + `e2e_runs`.
|
||||
2. **`/fix-flow <NNNN>`**: simetrico a `/fix-issue`. Cierra DoD del flow ejecutando Acceptance + abriendo issues si falla algun step.
|
||||
3. **`fn-meta-orquestador`** (subagente nuevo): lee `dev/issues/` con `status=pendiente` + dep-graph + telemetria. Spawn N `fn-orquestador` en worktrees paralelos respetando deps. Reusa `parallel-fix-issues/scripts/setup-worktrees.sh`.
|
||||
4. **`fn-priorizador`** (subagente nuevo): lee issues + telemetria call_monitor (error_rate, blast radius, huerfanas, violations). Output: top-N reordenado para `/work today`.
|
||||
5. **`fn doctor issues` + `fn doctor flows`**: valida TAXONOMY allowlist + DoD presente + user-facing surface declarada.
|
||||
1. **`fn-meta-orquestador`** (subagente nuevo): lee `dev/issues/` con `status=pendiente` + dep-graph + telemetria. Spawn N `fn-orquestador` en worktrees paralelos respetando deps. Reusa `parallel-fix-issues/scripts/setup-worktrees.sh`.
|
||||
2. **`fn-priorizador`** (subagente nuevo): lee issues + telemetria call_monitor (error_rate, blast radius, huerfanas, violations). Output: top-N reordenado para `/work today`.
|
||||
3. **`fn doctor issues` + `fn doctor flows`**: valida TAXONOMY allowlist + DoD presente + user-facing surface declarada.
|
||||
|
||||
**ELIMINADAS del scope original (absorbidas por `/autopilot` v2):**
|
||||
- `/flow run <NNNN>` — ahora `/autopilot flow:<NNNN>` delega a `fn-orquestador` con `task_type=flow`.
|
||||
- `/fix-flow <NNNN>` — mismo, fusion en `/autopilot`.
|
||||
|
||||
Implica que `fn-orquestador` necesita ganar soporte `task_type=flow` (parsear `## Flow` + ejecutar steps). Sub-tarea trackeada en 0123 reducido o issue propio (decidir).
|
||||
|
||||
## Tareas
|
||||
|
||||
1. Implementar `/flow run` en `.claude/commands/flow.md` + parser de Acceptance + dispatcher de steps.
|
||||
2. Implementar `/fix-flow` espejando `/fix-issue` adaptado al frontmatter de flows.
|
||||
3. Escribir `.claude/agents/fn-meta-orquestador/SKILL.md` con dep-graph resolver + spawner paralelo.
|
||||
4. Escribir `.claude/agents/fn-priorizador/SKILL.md` que consulta `call_monitor.operations.db` + `task_runs`.
|
||||
5. Anadir subcomandos `fn doctor issues` + `fn doctor flows` con funciones auxiliares via `fn-constructor`.
|
||||
6. Test: lanzar `/fix-flow 0001` (hn-top-stories) end-to-end + verificar acceptance.
|
||||
1. Anadir soporte `task_type=flow` a `fn-orquestador/SKILL.md` (parser `## Flow` + ejecutor steps + evaluator `## Acceptance` checkboxes via heuristicas).
|
||||
2. Escribir `.claude/agents/fn-meta-orquestador/SKILL.md` con dep-graph resolver + spawner paralelo.
|
||||
3. Escribir `.claude/agents/fn-priorizador/SKILL.md` que consulta `call_monitor.operations.db` + `task_runs`.
|
||||
4. Anadir subcomandos `fn doctor issues` + `fn doctor flows` con funciones auxiliares via `fn-constructor`.
|
||||
5. Test: lanzar `/autopilot flow:0001` (hn-top-stories) end-to-end + verificar acceptance.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `/flow run 0001` ejecuta cada step y reporta pass/fail por step.
|
||||
- [ ] `/fix-flow 0001` cierra DoD verde y mueve a `dev/flows/completed/`.
|
||||
- [ ] `/autopilot flow:0001` ejecuta cada step y reporta pass/fail por step (delegado a fn-orquestador con task_type=flow).
|
||||
- [ ] `/autopilot flow:0001` cierra DoD verde y mueve a `dev/flows/completed/`.
|
||||
- [ ] `fn-meta-orquestador` lanza N orquestadores paralelos sobre issues sin dep entre si.
|
||||
- [ ] `fn-priorizador` output incluye senal de telemetria (no solo prio+deps).
|
||||
- [ ] `fn doctor issues --json` detecta drift TAXONOMY.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
id: "0128"
|
||||
title: "agents_and_robots: HTTP API + SSE + apikey + TLS subdominio"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- agents
|
||||
- infra
|
||||
- deploy
|
||||
scope: app
|
||||
priority: alta
|
||||
depends: []
|
||||
blocks:
|
||||
- "0129"
|
||||
related: []
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [agents_and_robots, http, sse, apikey, traefik, systemd]
|
||||
dod_evidence_schema:
|
||||
- id: build_ok
|
||||
kind: cmd
|
||||
expected: "cd projects/element_agents/apps/agents_and_robots && go build -tags goolm ./cmd/launcher → exit 0"
|
||||
required: true
|
||||
- id: api_list_authorized
|
||||
kind: cmd
|
||||
expected: "curl -fsS -H 'Authorization: Bearer $AGENTS_API_KEY' https://agents.organic-machine.com/agents devuelve JSON con N>=7 agentes"
|
||||
required: true
|
||||
- id: api_list_unauthorized_401
|
||||
kind: cmd
|
||||
expected: "curl -s -o /dev/null -w '%{http_code}' https://agents.organic-machine.com/agents == 401"
|
||||
required: true
|
||||
- id: api_start_stop_roundtrip
|
||||
kind: cmd
|
||||
expected: "POST /agents/test-bot/stop → POST /agents/test-bot/start: status running confirmado via GET /agents/test-bot tras 2s"
|
||||
required: true
|
||||
- id: sse_logs_streaming
|
||||
kind: cmd
|
||||
expected: "curl -N -H 'Authorization: Bearer $KEY' https://agents.organic-machine.com/sse/agents/assistant-bot/logs entrega >=1 line en 5s con agente activo"
|
||||
required: true
|
||||
- id: sse_status_broadcast
|
||||
kind: cmd
|
||||
expected: "curl -N /sse/status recibe evento {agent_id, old_status, new_status} tras stop/start manual"
|
||||
required: true
|
||||
- id: systemd_active
|
||||
kind: cmd
|
||||
expected: "ssh organic-machine.com 'systemctl is-active agents_and_robots.service' == active"
|
||||
required: true
|
||||
- id: traefik_route
|
||||
kind: url
|
||||
expected: "agents.organic-machine.com resuelve y devuelve cert LE valido (curl -vI muestra subject CN=agents.organic-machine.com)"
|
||||
required: true
|
||||
- id: app_md_drift_fixed
|
||||
kind: cmd
|
||||
expected: "fn doctor services-spec apps/element_agents/apps/agents_and_robots reporta OK (sin drift runtime/systemd)"
|
||||
required: true
|
||||
---
|
||||
|
||||
# 0128 — agents_and_robots HTTP API + SSE + apikey + TLS
|
||||
|
||||
## Contexto
|
||||
|
||||
Hoy `agents_and_robots` solo expone control via `agentctl` CLI local (filesystem-based, `shell/process.Manager`). No hay forma remota de gestionar agentes.
|
||||
|
||||
Necesitamos backend HTTP seguro para que un frontend local C++ (issue 0129) pueda listar, start/stop/restart agentes, y streamear logs/status en vivo.
|
||||
|
||||
## Decision
|
||||
|
||||
**Integrar daemon HTTP DENTRO de `cmd/launcher`** como goroutine. Comparte `process.Manager` + acceso a `shell/memory/*.db` + Matrix clients. Un solo proceso, sin drift entre daemon y supervisor.
|
||||
|
||||
**Auth:** `Authorization: Bearer <AGENTS_API_KEY>` con `subtle.ConstantTimeCompare`. Clave 32 bytes hex en `.env` (`AGENTS_API_KEY`). 401 sin header o key invalida.
|
||||
|
||||
**TLS:** Traefik en VPS organic-machine.com con LE cert auto. Subdominio `agents.organic-machine.com` (DNS A record nuevo → IP del VPS). Ruta Traefik `agents.organic-machine.com → 127.0.0.1:8487`.
|
||||
|
||||
**SSE in-memory pubsub.** NATS OFF de momento (1 cliente local, broker = overhead). Documentar TODO en app.md para anadir bus si llega 2do consumidor.
|
||||
|
||||
## Scope v0.1 (lean)
|
||||
|
||||
| Verbo | Path | Wrap |
|
||||
|---|---|---|
|
||||
| GET | `/health` | 200 OK sin auth (liveness) |
|
||||
| GET | `/agents` | `Scan` + `StatusAll` + `msg_count_24h` (query `shell/memory/*.db`) |
|
||||
| GET | `/agents/{id}` | detail + config + `LogTail(200)` |
|
||||
| POST | `/agents/{id}/start` | `Manager.Start` |
|
||||
| POST | `/agents/{id}/stop` | `Manager.Stop` |
|
||||
| POST | `/agents/{id}/restart` | Stop+Start con espera health |
|
||||
| GET | `/agents/{id}/logs?n=200` | `LogTail` snapshot |
|
||||
|
||||
**SSE:**
|
||||
- `GET /sse/status` — broadcast cambios de status (poll cada 2s + diff)
|
||||
- `GET /sse/agents/{id}/logs` — tail -f del logfile, emite line events
|
||||
|
||||
**Fuera de scope v0.1** (queda v0.2):
|
||||
- POST `/agents/{id}/message` (send Matrix message)
|
||||
- PUT `/agents/{id}/config` (config edit)
|
||||
- SSE messages stream
|
||||
|
||||
## Tareas
|
||||
|
||||
1. **Nuevo paquete `internal/api`** con server HTTP (stdlib `net/http`, sin gin/echo).
|
||||
- `api.New(mgr *process.Manager, apiKey string, port int) *Server`
|
||||
- `Server.Run(ctx) error` arranca y bloquea hasta ctx done.
|
||||
- Middleware: log + auth + recover.
|
||||
2. **Handlers REST** sobre `process.Manager`. Tests unitarios con mock manager.
|
||||
3. **SSE pubsub in-memory** (`internal/api/pubsub.go`):
|
||||
- `Bus` con `Subscribe(topic) <-chan event` + `Publish(topic, event)`.
|
||||
- Poller goroutine que llama `StatusAll` cada 2s y publica diffs.
|
||||
- Tail goroutine por logfile (`file_tail_follow` — buscar en registry o crear).
|
||||
4. **Integrar en launcher** — `cmd/launcher/main.go` arranca `api.Server` en goroutine si `--api-port > 0`.
|
||||
5. **Crear systemd unit** `/etc/systemd/system/agents_and_robots.service` con `Restart=always`, `EnvironmentFile=.env`, `ExecStart=.../bin/launcher --log-level info --api-port 8487`.
|
||||
6. **Traefik route + DNS:**
|
||||
- Anadir `agents.organic-machine.com` en DNS (A record).
|
||||
- Anadir config Traefik (label en docker-compose del stack o file provider) apuntando a `127.0.0.1:8487`.
|
||||
7. **Fix drift app.md** — `runtime: systemd-system` ahora es verdad. Verificar con `fn doctor services-spec`.
|
||||
8. **Tests:**
|
||||
- Go: pkg `internal/api` con httptest.
|
||||
- e2e: `e2e_checks` en `app.md` con curl smoke.
|
||||
9. **Deploy:**
|
||||
- `rsync_deploy_bash_infra` o `deploy_server` target nuevo.
|
||||
- Generar `AGENTS_API_KEY` con `openssl rand -hex 32` y escribir `.env` remoto.
|
||||
- `systemctl enable --now agents_and_robots.service`.
|
||||
|
||||
## Funciones del registry a usar / proponer
|
||||
|
||||
Buscar antes de codear:
|
||||
|
||||
- `mcp__registry__fn_search query="tail follow file" lang="go"` — ¿existe `file_tail_follow_go_infra`? Si no, delegar a fn-constructor.
|
||||
- `mcp__registry__fn_search query="http auth bearer" lang="go"` — middleware auth.
|
||||
- `mcp__registry__fn_search query="sse server" lang="go"` — helper SSE.
|
||||
- `systemd_generate_unit_go_infra` + `systemd_install_go_infra` — generar/instalar unit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] `curl -fsS -H 'Authorization: Bearer $KEY' https://agents.organic-machine.com/agents` devuelve lista correcta.
|
||||
- [ ] Sin header → 401. Con key invalida → 401. Key valida → 200.
|
||||
- [ ] Start/Stop/Restart cambian estado real del proceso (verificable con `ps`).
|
||||
- [ ] SSE logs entrega lineas en menos de 1s de aparecer en el archivo.
|
||||
- [ ] SSE status broadcast tras stop/start manual.
|
||||
- [ ] systemd unit activo y reinicia tras kill -9.
|
||||
- [ ] `fn doctor services-spec` reporta OK.
|
||||
- [ ] Tests Go pasan.
|
||||
|
||||
## DoD humano
|
||||
|
||||
- **Donde:** terminal local → `curl https://agents.organic-machine.com/agents`. SSE verificable con `curl -N`.
|
||||
- **Latencia:** SSE log lag < 1s. REST list < 200ms.
|
||||
- **Onboarding:** README de agents_and_robots actualizado con seccion "HTTP API" + ejemplos curl.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- DNS propagation puede tardar (configurar con TTL bajo).
|
||||
- Traefik en este VPS: verificar si esta gestionado por Coolify o standalone — anadir ruta donde corresponda.
|
||||
- `LogTail` actual solo lee snapshot — necesitamos `tail -f` real para SSE. Si no existe en el registry, ronda previa.
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
id: "0129"
|
||||
title: "agents_dashboard: C++ ImGui frontend para gestionar agentes Matrix"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- agents
|
||||
- tui
|
||||
scope: app
|
||||
priority: alta
|
||||
depends:
|
||||
- "0128"
|
||||
blocks: []
|
||||
related: []
|
||||
created: 2026-05-22
|
||||
updated: 2026-05-22
|
||||
tags: [cpp, imgui, agents, dashboard, sse, http-client]
|
||||
dod_evidence_schema:
|
||||
- id: scaffold_ok
|
||||
kind: cmd
|
||||
expected: "ls projects/element_agents/apps/agents_dashboard/{app.md,main.cpp,CMakeLists.txt,.git} todos existen"
|
||||
required: true
|
||||
- id: build_windows
|
||||
kind: cmd
|
||||
expected: "cmake --build cpp/build/windows --target agents_dashboard -j → exit 0"
|
||||
required: true
|
||||
- id: appicon_embedded
|
||||
kind: cmd
|
||||
expected: "x86_64-w64-mingw32-objdump -h cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe | grep .rsrc"
|
||||
required: true
|
||||
- id: hub_card_visible
|
||||
kind: screenshot
|
||||
expected: "App Hub muestra tarjeta agents_dashboard con icono robot violeta + description correcta"
|
||||
required: true
|
||||
- id: connection_flow
|
||||
kind: screenshot
|
||||
expected: "Panel Connection con base_url + apikey input, LED verde tras handshake exitoso con backend"
|
||||
required: true
|
||||
- id: agents_table_populated
|
||||
kind: screenshot
|
||||
expected: "Tabla Agents muestra >=7 filas con id/status/uptime/msg_24h + botones accion"
|
||||
required: true
|
||||
- id: start_stop_works
|
||||
kind: screenshot
|
||||
expected: "Click stop sobre test-bot lo apaga (status cambia a stopped en menos de 2s); click start lo reinicia"
|
||||
required: true
|
||||
- id: logs_sse_streaming
|
||||
kind: screenshot
|
||||
expected: "Panel Logs streamea lineas en vivo de assistant-bot (lineas nuevas aparecen sin pulsar refresh)"
|
||||
required: true
|
||||
- id: apikey_encrypted_local
|
||||
kind: cmd
|
||||
expected: "strings cpp/build/windows/apps/agents_dashboard/local_files/agents_dashboard.db | grep -v '<plaintext apikey>' (apikey no aparece en claro)"
|
||||
required: true
|
||||
- id: e2e_self_test
|
||||
kind: cmd
|
||||
expected: "agents_dashboard.exe --self-test exit 0 (verifica subsistemas: GL loader, http client, SSE client, DB local)"
|
||||
required: true
|
||||
---
|
||||
|
||||
# 0129 — agents_dashboard C++ ImGui frontend
|
||||
|
||||
## Contexto
|
||||
|
||||
Cuando 0128 cierre, el backend `agents_and_robots` expondra HTTPS API + SSE en `agents.organic-machine.com` con apikey. Necesitamos frontend local C++ ImGui que consuma esa API y permita gestionar agentes sin SSH ni terminal.
|
||||
|
||||
## Decision
|
||||
|
||||
C++ ImGui app en `projects/element_agents/apps/agents_dashboard/`. Sub-repo Gitea `dataforge/agents_dashboard`. Integrada en App Hub con icono propio.
|
||||
|
||||
Scope v0.1 = lo que 0128 expone: list + start/stop/restart + logs SSE. v0.2 anade send-message + config-edit cuando backend los exponga.
|
||||
|
||||
## Tareas
|
||||
|
||||
### 1. Scaffold (REGLA: scaffolder canonico, NUNCA a mano)
|
||||
|
||||
```bash
|
||||
./fn run init_cpp_app agents_dashboard \
|
||||
--project element_agents \
|
||||
--desc "Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS+apikey, SSE para logs/status en vivo"
|
||||
```
|
||||
|
||||
Tras scaffold:
|
||||
- `git init` dentro de `projects/element_agents/apps/agents_dashboard/` (regla `apps_subrepo.md`).
|
||||
- Trio `app.md`: `description` + `icon.phosphor: "robot"` + `icon.accent: "#8b5cf6"`.
|
||||
- `./fn run regenerate_app_icons agents_dashboard`.
|
||||
- `./fn run refresh_app_hub` para que aparezca en el hub.
|
||||
|
||||
### 2. Funciones del registry — buscar primero
|
||||
|
||||
| Necesidad | Buscar en registry | Si falta |
|
||||
|---|---|---|
|
||||
| HTTP client C++ (sync GET/POST + Bearer + JSON body) | `mcp__registry__fn_search query="http client" lang="cpp"` | Delegar `fn-constructor`: `http_client_cpp_infra` con libcurl |
|
||||
| SSE client C++ | `sse_client_cpp_core` (FRESH 7d) | ✓ reuso directo |
|
||||
| JSON parse/serialize C++ | buscar nlohmann wrapper | Si falta, vendoring `cpp/vendor/json.hpp` (single-header) |
|
||||
| Data table | `data_table_cpp_viz` | ✓ reuso |
|
||||
| Secret store local (DPAPI Windows) | buscar | Si falta: `secret_store_cpp_infra` (DPAPI wrap, base64 fallback Linux) |
|
||||
| Ring buffer C++ | buscar | Si falta: `ring_buffer_cpp_core` |
|
||||
|
||||
Delegacion paralela: **una sola llamada Agent con N tool_use blocks paralelos** para las que falten (regla `delegation.md`).
|
||||
|
||||
### 3. Paneles UI
|
||||
|
||||
- **Connection** — `base_url` input + apikey input (mask) + boton "Test" → GET /health + GET /agents. LED estado SSE (gris/amarillo/verde/rojo). Save credentials en `local_files/agents_dashboard.db` encriptadas via secret_store.
|
||||
- **Agents** — `data_table_cpp_viz` con cols:
|
||||
- id (texto)
|
||||
- status (icono colored: running=green, stopped=gray, crashed=red)
|
||||
- uptime (humanized)
|
||||
- msg_24h (numero)
|
||||
- actions (botones `▶ ⏹ ↻` por fila)
|
||||
- Filtro por substring + sort por col.
|
||||
- **Logs** — selector agente (combo) + tail viewport (ring buffer 5000 lineas) + autoscroll toggle + boton "Pause". Stream via `/sse/agents/{id}/logs`.
|
||||
- **Status feed** — panel collapsible con eventos del `/sse/status` (timeline reciente).
|
||||
|
||||
### 4. Persistencia local
|
||||
|
||||
- `<exe_dir>/local_files/agents_dashboard.db` (SQLite via funciones del registry o sqlite3 directo).
|
||||
- Schema migraciones en `migrations/001_init.sql`:
|
||||
```sql
|
||||
CREATE TABLE connections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
apikey_encrypted BLOB NOT NULL,
|
||||
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
```
|
||||
- `app_settings.ini` via `fn_ui::settings_*` (theme, layout).
|
||||
- apikey cifrada con DPAPI Windows (clave nunca abandona la maquina).
|
||||
|
||||
### 5. Build + deploy local
|
||||
|
||||
- CMake target `agents_dashboard` en `cpp/CMakeLists.txt` (auto via scaffolder).
|
||||
- Build Windows: `cmake --build cpp/build/windows --target agents_dashboard -j`.
|
||||
- Deploy local: `./fn run redeploy_cpp_app_windows agents_dashboard projects/element_agents/apps/agents_dashboard --build`.
|
||||
- Icono via windres (gestionado por `add_imgui_app`).
|
||||
|
||||
### 6. Tests + e2e_checks
|
||||
|
||||
```yaml
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "cmake --build cpp/build/windows --target agents_dashboard -j"
|
||||
timeout_s: 180
|
||||
- id: self_test
|
||||
cmd: "./cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe --self-test"
|
||||
timeout_s: 30
|
||||
- id: pytest_mock
|
||||
cmd: "cd projects/element_agents/apps/agents_dashboard/tests && python3 -m pytest -x -q"
|
||||
timeout_s: 60
|
||||
```
|
||||
|
||||
Mock server pytest emula 0128 (list/start/stop + SSE) y verifica que la app C++ conecta + popula tabla + start/stop funciona en headless con `--capture` mode.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] App arranca, muestra Connection panel.
|
||||
- [ ] Tras meter apikey valida → tabla Agents populated con datos reales de VPS.
|
||||
- [ ] Stop/Start desde UI cambia estado real del agente en VPS.
|
||||
- [ ] Logs streamea lineas nuevas sin polling.
|
||||
- [ ] Cerrar y reabrir app → credentials persisten (cifradas).
|
||||
- [ ] Sin red / apikey invalida → error visible, app no crashea.
|
||||
- [ ] `--self-test` exit 0.
|
||||
- [ ] Visible en App Hub con icono + description correctos.
|
||||
|
||||
## DoD humano
|
||||
|
||||
- **Donde:** Windows Desktop → App Hub → Click "agents_dashboard".
|
||||
- **Latencia:** logs SSE < 1s lag. Lista agents < 200ms tras handshake.
|
||||
- **Onboarding:** First-run wizard pide base_url + apikey; tooltip explica donde obtener la key (gestor de secretos del VPS).
|
||||
|
||||
## Riesgos
|
||||
|
||||
- libcurl en Windows mingw-w64: cross-compile setup. Si `http_client_cpp_infra` no existe, dedicar tiempo al wrapper antes de UI.
|
||||
- DPAPI solo Windows: fallback Linux puede ser texto plano con permisos 0600 + warning visible en UI.
|
||||
- SSE reconnect logic: backoff exponencial + indicador de estado claro.
|
||||
@@ -0,0 +1,186 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MCPHTTPAuthFunc validates an incoming HTTP request and returns an enriched
|
||||
// context (e.g. with user_id) or an error. When it returns an error the
|
||||
// handler replies 401 Unauthorized without invoking the tool handler.
|
||||
// If nil, no auth is performed.
|
||||
type MCPHTTPAuthFunc func(r *http.Request) (context.Context, error)
|
||||
|
||||
// MCPHTTPOpts configures the Streamable HTTP MCP handler.
|
||||
type MCPHTTPOpts struct {
|
||||
Name string // server name reported to the client in initialize
|
||||
Version string // server version reported to the client in initialize
|
||||
Tools []MCPToolDef // reuses MCPToolDef from mcp_server_stdio.go
|
||||
Handler MCPToolHandler // reuses MCPToolHandler from mcp_server_stdio.go
|
||||
Auth MCPHTTPAuthFunc // optional; if nil, no auth
|
||||
Logger io.Writer // optional log sink; discards when nil
|
||||
}
|
||||
|
||||
const mcpHTTPBodyLimit = 1 << 20 // 1 MiB
|
||||
|
||||
// MCPHTTPHandler returns an http.Handler that implements the Streamable HTTP
|
||||
// MCP transport (spec 2025-03-26).
|
||||
//
|
||||
// Mount at any single path (e.g. /mcp). Handles POST for client→server
|
||||
// JSON-RPC 2.0 requests. GET and DELETE return 405 Method Not Allowed (SSE
|
||||
// server→client streaming is not implemented — see Gotchas in the .md).
|
||||
//
|
||||
// The handler is safe for concurrent use; it carries no shared mutable state.
|
||||
func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler {
|
||||
logf := func(format string, args ...any) {
|
||||
if opts.Logger != nil {
|
||||
fmt.Fprintf(opts.Logger, "[mcp-http] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
// handled below
|
||||
case http.MethodGet, http.MethodDelete:
|
||||
// SSE server→client and session close not implemented yet.
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional auth: validate request and (optionally) enrich context.
|
||||
ctx := r.Context()
|
||||
if opts.Auth != nil {
|
||||
enriched, err := opts.Auth(r)
|
||||
if err != nil {
|
||||
logf("auth rejected: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
ctx = enriched
|
||||
}
|
||||
|
||||
// Read body with size limit (anti-DoS).
|
||||
limitedBody := http.MaxBytesReader(w, r.Body, mcpHTTPBodyLimit)
|
||||
body, err := io.ReadAll(limitedBody)
|
||||
if err != nil {
|
||||
// MaxBytesReader wraps the error; treat any read failure as 413.
|
||||
logf("body read error: %v", err)
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
logf("recv: %s", body)
|
||||
|
||||
// Parse JSON-RPC request. On parse failure respond HTTP 200 with
|
||||
// JSON-RPC error -32700 (per MCP spec — not HTTP 400).
|
||||
var req jsonrpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
logf("json parse error: %v", err)
|
||||
writeJSONRPCError(w, nil, -32700, "parse error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Notifications (no "id" key in raw JSON) → 202 Accepted, no body.
|
||||
isNotification := !jsonHasKey(body, "id")
|
||||
if isNotification {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch method.
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result := map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{
|
||||
"name": opts.Name,
|
||||
"version": opts.Version,
|
||||
},
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, result)
|
||||
|
||||
case "initialized":
|
||||
// Should not arrive as a non-notification, but handle gracefully.
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{})
|
||||
|
||||
case "tools/list":
|
||||
tools := opts.Tools
|
||||
if tools == nil {
|
||||
tools = []MCPToolDef{}
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{"tools": tools})
|
||||
|
||||
case "tools/call":
|
||||
var p mcpCallParams
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
writeJSONRPCError(w, req.ID, -32602, "invalid params: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
args := p.Arguments
|
||||
if args == nil {
|
||||
args = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
toolResult, isErr, handlerErr := opts.Handler(ctx, p.Name, args)
|
||||
if handlerErr != nil {
|
||||
logf("handler error for %q: %v", p.Name, handlerErr)
|
||||
writeJSONRPCError(w, req.ID, -32603, handlerErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resultText, _ := json.Marshal(toolResult)
|
||||
callResult := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": string(resultText),
|
||||
},
|
||||
},
|
||||
"isError": isErr,
|
||||
}
|
||||
writeJSONRPCResult(w, req.ID, callResult)
|
||||
|
||||
case "ping":
|
||||
writeJSONRPCResult(w, req.ID, map[string]any{})
|
||||
|
||||
default:
|
||||
logf("unknown method %q", req.Method)
|
||||
writeJSONRPCError(w, req.ID, -32601, "method not found: "+req.Method)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONRPCResult writes a JSON-RPC 2.0 success response.
|
||||
func writeJSONRPCResult(w http.ResponseWriter, id any, result any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(jsonrpcResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: result,
|
||||
})
|
||||
}
|
||||
|
||||
// writeJSONRPCError writes a JSON-RPC 2.0 error response with HTTP 200.
|
||||
// Per the MCP Streamable HTTP spec, protocol errors still use HTTP 200 so
|
||||
// the client can parse the JSON-RPC error object (not HTTP status codes).
|
||||
func writeJSONRPCError(w http.ResponseWriter, id any, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(jsonrpcResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &jsonrpcError{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: mcp_server_http
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler"
|
||||
description: "Devuelve un http.Handler que implementa el Streamable HTTP transport del protocolo MCP (spec 2025-03-26). Acepta POST con un mensaje JSON-RPC 2.0 unico y despacha initialize/tools/list/tools/call/ping al handler del usuario. Soporta auth opcional via MCPHTTPAuthFunc que enriquece el context antes de invocar el handler de tools."
|
||||
tags: [mcp, http, rpc, json-rpc, tools, server, protocol, claude, backends]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- context
|
||||
- encoding/json
|
||||
- fmt
|
||||
- io
|
||||
- net/http
|
||||
tested: true
|
||||
tests:
|
||||
- "Initialize retorna serverInfo con Name y Version correctos"
|
||||
- "ToolsList retorna las tools registradas"
|
||||
- "ToolsCall invoca handler y retorna content[0].text con el resultado"
|
||||
- "BadAuth retorna 401 cuando opts.Auth devuelve error"
|
||||
- "BodyTooLarge retorna 413 cuando el body supera 1 MiB"
|
||||
- "ParseError retorna HTTP 200 con error JSON-RPC -32700 para body invalido"
|
||||
- "Notification retorna 202 Accepted sin body cuando falta el campo id"
|
||||
- "MethodNotAllowed retorna 405 para GET y DELETE"
|
||||
test_file_path: "functions/infra/mcp_server_http_test.go"
|
||||
file_path: "functions/infra/mcp_server_http.go"
|
||||
params:
|
||||
- name: opts.Name
|
||||
desc: "Nombre del servidor reportado al cliente en la respuesta de initialize (serverInfo.name)."
|
||||
- name: opts.Version
|
||||
desc: "Version del servidor reportada al cliente en initialize (serverInfo.version)."
|
||||
- name: opts.Tools
|
||||
desc: "Lista de MCPToolDef (nombre, descripcion, JSON Schema del input) que el servidor expone. Mismo tipo que MCPServerOpts de mcp_server_stdio_go_infra."
|
||||
- name: opts.Handler
|
||||
desc: "Dispatcher unico para todas las tools. Recibe ctx (posiblemente enriquecido por Auth), nombre de la tool y arguments JSON crudo. Devuelve result, isError y err."
|
||||
- name: opts.Auth
|
||||
desc: "Funcion opcional de autenticacion. Recibe el *http.Request, devuelve un context enriquecido (p.ej. con user_id) o un error. Si devuelve error, el handler responde 401 sin invocar Handler. Si es nil no se hace auth."
|
||||
- name: opts.Logger
|
||||
desc: "Writer opcional para log de debug (p.ej. os.Stderr). Si es nil los mensajes se descartan."
|
||||
output: "http.Handler listo para montarse en un mux. El handler es seguro para uso concurrente (no tiene estado mutable compartido)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tools := []infra.MCPToolDef{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Devuelve el mensaje tal cual",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {"msg": {"type": "string"}},
|
||||
"required": ["msg"]
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
toolHandler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
|
||||
if name == "echo" {
|
||||
var args struct{ Msg string `json:"msg"` }
|
||||
if err := json.Unmarshal(input, &args); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return map[string]string{"result": args.Msg}, false, nil
|
||||
}
|
||||
return nil, true, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
// AuthFunc Bearer simple: extrae el token, busca en DB, inyecta user_id en ctx.
|
||||
// El kanban lo implementa asi, buscando en la tabla mcp_tokens.
|
||||
authFn := func(r *http.Request) (context.Context, error) {
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("missing token")
|
||||
}
|
||||
// ... validar token en DB ...
|
||||
ctx := context.WithValue(r.Context(), "user_id", "u_123")
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
mcpH := infra.MCPHTTPHandler(infra.MCPHTTPOpts{
|
||||
Name: "my-app-mcp",
|
||||
Version: "1.0.0",
|
||||
Tools: tools,
|
||||
Handler: toolHandler,
|
||||
Auth: authFn,
|
||||
Logger: os.Stderr,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcpH)
|
||||
|
||||
fmt.Println("MCP HTTP server en :8300/mcp")
|
||||
if err := http.ListenAndServe(":8300", mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para un cliente MCP HTTP, la configuracion en `.mcp.json` usa `type: http`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-app": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:8300/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites exponer tools MCP a Claude o a un agente via HTTP en lugar de via stdio — es decir, cuando el servidor MCP es un proceso separado (no un subproceso del cliente) o cuando varios clientes deben compartir el mismo servidor. Tipico en apps con backend Go ya existente (kanban, sqlite_api, registry_api) que quieren aceptar llamadas MCP sin lanzar un subproceso nuevo por sesion.
|
||||
|
||||
Usa `mcp_server_stdio_go_infra` si el cliente lanza el servidor como subproceso (Claude Desktop, `claude -p`). Usa esta funcion si el servidor ya esta corriendo y el cliente se conecta via URL.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Sin sesiones MCP (Mcp-Session-Id)**: la spec 2025-03-26 define un header `Mcp-Session-Id` para multiplexar sesiones sobre el mismo endpoint. Esta implementacion no lo emite ni lo exige — cada POST es independiente. TODO: implementar sesiones cuando se necesiten mas de un cliente concurrente con estado de sesion separado.
|
||||
- **GET (SSE server→client) no implementado**: POST cubre el 100% de los casos de uso actuales (tools call). El canal SSE (server-initiated notifications) se puede anadir montando un segundo handler GET en el mismo path. Devuelve 405 hasta entonces.
|
||||
- **DELETE (close session) no implementado**: 405. Sin sesiones, no hay nada que cerrar.
|
||||
- **Body limit 1 MiB**: requests mas grandes reciben 413. Si tus tools reciben inputs grandes (imagenes en base64, JSONs voluminosos), ajusta `mcpHTTPBodyLimit` en el .go o recibe los datos por referencia (URL/path) en vez de inline.
|
||||
- **CORS no incluido**: monta `http_cors_middleware_go_infra` en el mux antes del handler si el cliente MCP es un frontend web o viene de origen diferente.
|
||||
- **Errores de protocolo usan HTTP 200**: segun el spec MCP, los errores JSON-RPC se devuelven con HTTP 200 para que el cliente pueda parsear el objeto error. Solo `401` y `413` son codigos HTTP de error.
|
||||
@@ -0,0 +1,201 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
func newTestMCPHandler(auth MCPHTTPAuthFunc) http.Handler {
|
||||
tools := []MCPToolDef{
|
||||
{
|
||||
Name: "greet",
|
||||
Description: "Returns a greeting",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`),
|
||||
},
|
||||
}
|
||||
handler := func(_ context.Context, name string, _ json.RawMessage) (any, bool, error) {
|
||||
if name == "greet" {
|
||||
return map[string]string{"hello": "world"}, false, nil
|
||||
}
|
||||
return nil, true, errors.New("unknown tool")
|
||||
}
|
||||
return MCPHTTPHandler(MCPHTTPOpts{
|
||||
Name: "test-server",
|
||||
Version: "0.0.1",
|
||||
Tools: tools,
|
||||
Handler: handler,
|
||||
Auth: auth,
|
||||
})
|
||||
}
|
||||
|
||||
func postMCP(h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// --- tests ------------------------------------------------------------------
|
||||
|
||||
func TestMCPHTTPHandler_Initialize(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result, ok := resp.Result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result is not map: %T", resp.Result)
|
||||
}
|
||||
if _, ok := result["protocolVersion"]; !ok {
|
||||
t.Error("missing protocolVersion in result")
|
||||
}
|
||||
si, ok := result["serverInfo"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("missing serverInfo")
|
||||
}
|
||||
if si["name"] != "test-server" {
|
||||
t.Errorf("serverInfo.name = %v, want test-server", si["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ToolsList(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected rpc error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result := resp.Result.(map[string]any)
|
||||
tools, ok := result["tools"].([]any)
|
||||
if !ok || len(tools) == 0 {
|
||||
t.Fatalf("expected non-empty tools array, got %v", result["tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ToolsCall(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{}}}`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected rpc error: %+v", resp.Error)
|
||||
}
|
||||
|
||||
result := resp.Result.(map[string]any)
|
||||
content, ok := result["content"].([]any)
|
||||
if !ok || len(content) == 0 {
|
||||
t.Fatalf("expected content array, got %v", result["content"])
|
||||
}
|
||||
first := content[0].(map[string]any)
|
||||
if first["text"] != `{"hello":"world"}` {
|
||||
t.Errorf("content[0].text = %q, want {\"hello\":\"world\"}", first["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_BadAuth(t *testing.T) {
|
||||
auth := func(_ *http.Request) (context.Context, error) {
|
||||
return nil, errors.New("bad token")
|
||||
}
|
||||
h := newTestMCPHandler(auth)
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_BodyTooLarge(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
big := strings.Repeat("x", mcpHTTPBodyLimit+1)
|
||||
body := `{"jsonrpc":"2.0","id":5,"method":"initialize","params":{"x":"` + big + `"}}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("expected 413, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_ParseError(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
w := postMCP(h, `not valid json`)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200 for parse error, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp jsonrpcResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected JSON-RPC error, got nil")
|
||||
}
|
||||
if resp.Error.Code != -32700 {
|
||||
t.Errorf("error code = %d, want -32700", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_Notification(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
// A notification has no "id" key at all.
|
||||
w := postMCP(h, `{"jsonrpc":"2.0","method":"initialized","params":{}}`)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected 202 for notification, got %d", w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for notification, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHTTPHandler_MethodNotAllowed(t *testing.T) {
|
||||
h := newTestMCPHandler(nil)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
r := httptest.NewRequest(method, "/mcp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("%s: expected 405, got %d", method, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user