17 Commits

Author SHA1 Message Date
egutierrez 6aa874f2b6 done(0131): agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 23:10:32 +02:00
egutierrez 93352a7780 feat(issues): 0131 agents v0.2 — unified control + uptime/msg_24h + data_table + clear/cache 2026-05-22 22:47:02 +02:00
Egutierrez 0ffae6daa4 feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:20:15 +02:00
egutierrez 74b58cf0d0 fix(http_request): drop "2>&1" on Windows — CreateProcessW has no shell
POSIX popen routes via /bin/sh -c, so "2>&1" is a shell redirect. On
Windows we use CreateProcessW directly (no shell): curl receives "2>&1"
as a positional arg, treats it as a second URL, and fails with exit 3
"URL rejected: Bad hostname".

Stderr is already merged into the same pipe via STARTUPINFOW.hStdError
on Windows, so the redirect is also unnecessary there. Guard with
#ifndef _WIN32.

Also adds FN_HTTP_DEBUG env var to dump the cmdline + req.url for
future bug triage (zero-cost when unset).

Detected via agents_dashboard.exe --connect-test against
https://agents.organic-machine.com — same .exe with the fix now returns
"OK 11" in <2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:15:37 +02:00
egutierrez 9752fb106a done: 0128 + 0129 — agents_and_robots HTTP API + agents_dashboard C++ ImGui
Both issues delivered end-to-end:

0128 (backend, merged via dataforge/agents_and_robots/pulls/1):
- HTTP daemon in cmd/launcher with apikey Bearer auth + SSE
- LIVE at https://agents.organic-machine.com via Coolify Traefik + LE cert
- systemd Restart=always
- Unified status autodetect fix applied

0129 (frontend, merged via dataforge/agents_dashboard/pulls/1):
- C++ ImGui app in projects/element_agents/apps/agents_dashboard
- 4 panels: Connection / Agents / Logs / Status
- secret_store_cpp_infra new function (DPAPI Windows / XOR Linux)
- Deployed to Windows Desktop, App Hub tarjeta visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:58:05 +02:00
egutierrez 8cb0121573 merge: 0129 agents_dashboard scaffold + secret_store_cpp_infra + CMakeLists register 2026-05-22 21:57:04 +02:00
egutierrez 90115270d2 chore: auto-commit (3 archivos)
- cpp/functions/infra/secret_store.cpp
- cpp/functions/infra/secret_store.h
- cpp/functions/infra/secret_store.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:52:37 +02:00
egutierrez 11e6e27ad1 fix(auto): fix secret_store.md YAML — returns/error_type/imports format
- returns: [] (was empty string)
- error_type: error_go_core (was empty, required for impure)
- imports: [list] (was string)
- Removed stale id: field (auto-computed from filename)
- Added output: field for params_schema

fn index now clean: 1324 functions, 45 apps

Co-Authored-By: fn-orquestador <noreply@fn-registry.local>
2026-05-22 21:48:21 +02:00
egutierrez a59b12d467 feat(auto): construir iter 1 — add secret_store_cpp_infra registry function
DPAPI Windows + XOR Linux fallback para almacenar credentials sensibles
en SQLite local. Usado por agents_dashboard para cifrar apikeys.
Incluye encrypt/decrypt/is_strong + base64 helpers.

Issue: 0129
Co-Authored-By: fn-constructor <noreply@fn-registry.local>
2026-05-22 21:42:44 +02:00
egutierrez fe4320af89 chore(auto): construir iter 1 — scaffold agents_dashboard + register in CMakeLists
- init_cpp_app_bash_pipelines scaffold:
  projects/element_agents/apps/agents_dashboard/{main.cpp,CMakeLists.txt,app.md}
- git init dentro del sub-repo (apps_subrepo.md regla)
- Registrado en cpp/CMakeLists.txt (add_subdirectory via _AGENTS_DASHBOARD_DIR)

Co-Authored-By: fn-constructor <noreply@fn-registry.local>
2026-05-22 21:37:06 +02:00
egutierrez f71e0f4c9a chore: remove kanban_cpp app
Gitea repo dataforge/kanban_cpp archived (read-only).
Local apps/kanban_cpp/ deleted, CMake subdir registration removed.
registry.db entry + pc_locations row purged (regenerable via fn index +
manual delete since indexer upserts but does not purge orphaned apps).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:30:47 +02:00
egutierrez 46b4385331 feat(issues): 0128 agents_and_robots HTTP API + 0129 agents_dashboard C++ ImGui
0128 (backend, blocks 0129): HTTP daemon en cmd/launcher con apikey Bearer auth, SSE pubsub in-memory para status+logs, TLS via Traefik en agents.organic-machine.com, systemd Restart=always. Scope v0.1 lean: list/start/stop/restart/logs SSE. Send-message + config-edit en v0.2.

0129 (frontend): C++ ImGui agents_dashboard en projects/element_agents/apps/. Panels Connection/Agents/Logs/Status. Persistencia local cifrada DPAPI. Depende de 0128.

Ambos issues con dod_evidence_schema completo (9 + 9 items).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:11:30 +02:00
egutierrez 580238b32e feat(infra): auto-commit con 8 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:38:16 +02:00
egutierrez ed767360c1 chore: auto-commit (1 archivos)
- cpp/apps/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:17:04 +02:00
egutierrez 5bac05ce13 refactor(commands): merge /autonomous-task → /autopilot v2
Doble entrada confusa (incidente 2026-05-19 piloto 0121b: cwd
mutation por Path B inline causo commit a branch incorrecta).

Cambios:
- .claude/commands/autopilot.md: v2 simplificado. SOLO pre-flight
  DoD check + delegate fn-orquestador. Sin Path A/B/C inline.
  Self-Q&A migrado al orquestador. Cero cwd mutation.
- .claude/commands/autonomous-task.md: DEPRECADO. Sustitucion 1:1.
  Sigue funcionando como debug primitive sin DoD check.
- dev/issues/0123: revision — eliminar /flow run y /fix-flow (absorbidos
  por /autopilot v2). Mantener fn-meta-orquestador + fn-priorizador
  + fn doctor issues/flows. Anadir tarea: dar a fn-orquestador soporte
  task_type=flow.

Preferencia humano: 1 sola entrada autopilot, "modo que entra y sigue".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:52:19 +02:00
egutierrez d0ceea6f3d done(0121b): fn doctor e2e-coverage funcionando — 42.22% coverage actual
task_run task_d285372493cce2e6 converged 1 iter / ~4 min.
PR https://gitea-.../dataforge/fn_registry/pulls/3 mergeado.

Verificado en master:
  total=45 with_checks=19 coverage=42.22%
  21 apps con propuesta lista en dev/proposals_e2e_checks_0121/ esperando aplicacion (0121c).
  5 apps sin propuesta aun (wave 4 pendiente).

Desbloquea: 0121c (apply N PRs add_e2e_check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:48:15 +02:00
dataforge 0f905b78e0 merge(0121b): fn doctor e2e-coverage
task_run task_d285372493cce2e6 converged 1 iter / 4 min. 4/4 acceptance.

functions/infra/audit_e2e_coverage.go + .md + _test.go
types/infra/e2e_coverage_report.md
cmd/fn/doctor.go (subcmd e2e-coverage)

Cierra: dev/issues/0121b-fn-doctor-e2e-coverage.md
2026-05-18 23:47:32 +00:00
52 changed files with 3523 additions and 387 deletions
+2
View File
@@ -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`.
---
+1
View File
@@ -0,0 +1 @@
../../projects/aurgi/.claude/commands
+23 -108
View File
@@ -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
View File
@@ -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`.
+86
View File
@@ -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`).
+1
View File
@@ -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. |
+52
View File
@@ -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/...`).
+6
View File
@@ -530,6 +530,12 @@ if(EXISTS ${_PROCESS_EXPLORER_DIR}/CMakeLists.txt)
add_subdirectory(${_PROCESS_EXPLORER_DIR} ${CMAKE_BINARY_DIR}/apps/process_explorer)
endif()
# --- 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()
# --- kanban_cpp (lives in apps/, issue 0096) ---
set(_KANBAN_CPP_DIR ${CMAKE_SOURCE_DIR}/../apps/kanban_cpp)
if(EXISTS ${_KANBAN_CPP_DIR}/CMakeLists.txt)
Submodule cpp/apps/chart_demo added at 026f514bb7
Submodule cpp/apps/shaders_lab added at dc9a970aff
+15 -2
View File
@@ -269,8 +269,21 @@ Response request(const Request& req) {
}
cmd << ' ' << sh_q(req.url)
<< " -o " << sh_q(tmp_body_out)
<< " 2>&1";
<< " -o " << sh_q(tmp_body_out);
// On POSIX we go through /bin/sh -c via popen, so `2>&1` is a shell redirect.
// On Windows we use CreateProcessW (no shell): `2>&1` would be passed as an
// extra positional arg to curl, which treats it as a second URL → "Bad
// hostname" (exit 3). stderr is already merged via STARTUPINFOW.hStdError.
#ifndef _WIN32
cmd << " 2>&1";
#endif
if (std::getenv("FN_HTTP_DEBUG")) {
fprintf(stderr, "[fn_http debug] cmdline: %s\n", cmd.str().c_str());
fprintf(stderr, "[fn_http debug] req.url=[%s] len=%zu\n",
req.url.c_str(), req.url.size());
}
// Capture stderr (curl prints transport errors to stderr with -sS).
std::string curl_stderr;
+167
View File
@@ -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
+37
View File
@@ -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
+65
View File
@@ -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`.
+23 -17
View File
@@ -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.
+121
View File
@@ -0,0 +1,121 @@
---
id: "0130"
title: Kanban C++ v2 — gestor de dev/issues y dev/flows con backend Go + frontend ImGui
status: pendiente
type: epic
domain:
- cpp-stack
- apps-infra
- dev-ux
scope: multi-app
priority: alta
depends: []
blocks: []
related:
- "0112"
- "0119"
tags:
- kanban
- cpp
- imgui
- dev_ux
- issues
- flows
created: "2026-05-22"
updated: "2026-05-22"
---
# 0130 — Kanban C++ v2
**Status:** pendiente
## Por que
La v1 (`apps/kanban_cpp` borrada el 2026-05-22) mezclaba paneles ajenos al dominio kanban (agent runs, DoD, worktrees, calendar) y un backend que no era reutilizable. Para gestionar los 98 issues activos + 12 flows del proyecto necesitamos una vista board nativa, sin web, con edicion bidireccional de los archivos markdown.
## Que entrega
App kanban_cpp v2 con dos piezas:
1. **Backend Go** (`apps/kanban_cpp/backend/`) — service HTTP en puerto 8487.
- Parser bidireccional MD <-> SQLite (cache).
- Watcher fsnotify sobre `dev/issues/` (+ `completed/`) y `dev/flows/`.
- Endpoints REST: `/api/issues`, `/api/issues/{id}` (GET/PATCH), `/api/flows`, `/api/flows/{id}`, `/api/meta`, `/api/sse`.
- PATCH a issue reescribe el frontmatter en disco preservando body + orden de campos.
2. **Frontend C++ ImGui** (`apps/kanban_cpp/`) sobre el framework `fn::run_app`.
- Panel **Board**: columnas por status (pendiente / in-progress / bloqueado / completado). Drag-drop = PATCH status.
- Panel **Flows**: lista de flows con detalle.
- Panel **Filtros** (Aside): multi-select domain, scope, priority, tags.
- Panel **Detalle**: edicion de campos frontmatter de un issue (status, priority, scope, tags, depends, blocks).
- SSE para refrescar tras cambios externos en disco.
## Sub-issues
- **0130a** — parser MD + scan dirs (funciones registry).
- **0130b** — backend Go: schema + handlers + watcher + SSE.
- **0130c** — frontend C++: paneles + http client.
Cada sub-issue mergeable independiente en su rama corta TBD.
## Reusa del registry
Backend Go:
- `sqlite_open_go_infra`, `sqlite_apply_migrations_go_infra`
- `http_router_go_infra`, `http_serve_go_infra`, `http_middleware_chain_go_infra`
- `http_cors_middleware_go_infra`, `http_logger_middleware_go_infra`
- `http_json_response_go_infra`, `http_error_response_go_infra`, `http_parse_body_go_infra`
- `random_hex_id_go_core`
Frontend C++:
- `http_request_cpp_core`
- `sse_client_cpp_core`
- `data_table_cpp_viz` (lista flows)
- `kpi_card_cpp_viz` (contadores por status)
## Crea (delegadas a fn-constructor en 0130a)
- `parse_issue_md_go_infra` — lee .md → struct (frontmatter YAML + body).
- `write_issue_md_go_infra` — escribe struct → .md preservando body + orden de campos.
- `scan_issues_dir_go_infra` — walk `dev/issues/` + `dev/issues/completed/`.
- `scan_flows_dir_go_infra` — walk `dev/flows/`.
- `watch_dir_fsnotify_go_infra` (si no existe) — events channel.
## DoD
- `fn doctor` verde para ambas apps (artefacts + e2e).
- `e2e_checks` en ambos `app.md` (build + health + self-test).
- Drag-drop en frontend reescribe el `.md` correspondiente y `git diff` lo muestra (solo frontmatter, body intacto).
- Trio obligatorio (`description` + `icon.phosphor` + `icon.accent`) en ambos `app.md`.
- Sub-repos Gitea creados (`dataforge/kanban_cpp` reactivado o nuevo, mismo nombre).
dod_evidence_schema:
- id: backend_health
kind: cmd
expected: "curl -fsS http://localhost:8487/api/health == 200"
required: true
- id: api_issues_count
kind: cmd
expected: "curl -fsS http://localhost:8487/api/issues | jq 'length' >= 90"
required: true
- id: patch_writes_md
kind: cmd
expected: "PATCH /api/issues/0130 status=in-progress reescribe dev/issues/0130-*.md (git diff muestra solo status)"
required: true
- id: frontend_self_test
kind: cmd
expected: "./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test exit 0"
required: true
- id: board_screenshot
kind: screenshot
expected: "kanban_cpp Board panel con 4 columnas pobladas con issues reales"
required: true
## Anti-scope
NO incluye en esta version:
- Grafo de dependencias (depends/blocks/related visual).
- Edicion de body MD desde la app (solo frontmatter).
- Multi-PC sync (backend es local).
- Crear issues nuevos desde la UI (solo editar existentes).
- DoD evidence panel, agent runs, calendar, worktrees (la v1 los mezclaba — fuera).
+75
View File
@@ -0,0 +1,75 @@
---
id: 0130a
title: 'Funciones registry: parser MD + scan dirs + writer + watcher'
status: pendiente
type: infra
domain:
- registry-quality
- dev-ux
scope: registry-only
priority: alta
depends: []
blocks:
- 0130b
related:
- "0130"
tags:
- registry
- go
- parser
- frontmatter
- fsnotify
flow: "0130"
created: "2026-05-22"
updated: "2026-05-22"
---
# 0130a — Funciones registry para kanban_cpp v2
**Status:** pendiente
## Por que
El backend de kanban_cpp v2 necesita parsear/escribir frontmatter YAML de los `.md` de `dev/issues/` y `dev/flows/`. Estas piezas son reusables (cualquier app del registry puede operar sobre issues/flows), asi que viven en el registry, no en el backend de la app.
## Funciones a crear (delegar a fn-constructor en paralelo)
| ID | Firma | Pureza |
|---|---|---|
| `parse_issue_md_go_infra` | `(path string) (Issue, []byte body, error)` | impure (FS) |
| `write_issue_md_go_infra` | `(path string, issue Issue, body []byte) error` | impure (FS) |
| `scan_issues_dir_go_infra` | `(root string) ([]Issue, error)` | impure (FS) |
| `scan_flows_dir_go_infra` | `(root string) ([]Flow, error)` | impure (FS) |
| `watch_dir_fsnotify_go_infra` | `(ctx, root) (<-chan FsEvent, error)` | impure (FS, async) |
Tipos:
- `Issue_go_infra` — struct con campos del frontmatter (id, title, status, type, domain, scope, priority, depends, blocks, related, flow, tags, created, updated, file_path, mtime_ns).
- `Flow_go_infra` — struct equivalente para flows.
- `FsEvent_go_infra``{path, op}` con `op in {create, write, remove, rename}`.
## Notas de implementacion
- Usar `gopkg.in/yaml.v3` para parsing (preserva orden de keys via `yaml.Node`).
- Writer DEBE preservar:
- Orden de campos del frontmatter original.
- Body MD intacto (todo lo que va despues del segundo `---`).
- Comentarios YAML (libre, best-effort).
- `parse_issue_md` debe ser tolerante: si falta un campo opcional, default empty.
- `watch_dir_fsnotify` recursivo, debounce 200ms.
## DoD
- 5 pares `.go` + `.md` en `functions/infra/`.
- Tests unitarios:
- parse → write → parse round-trip preserva struct.
- scan_issues_dir devuelve >=90 issues actuales.
- watcher detecta creacion + modificacion + borrado.
- `fn index` registra los 5 IDs + 3 tipos.
- `fn doctor uses-functions` limpio.
## Anti-scope
NO incluye en esta tanda:
- Markdown rendering del body (eso lo hace el frontend si quiere).
- Validacion contra TAXONOMY (existe `fn doctor issues`).
- CRUD de issues nuevos (write_issue cubre el caso, pero crear file = scope del backend).
+114
View File
@@ -0,0 +1,114 @@
---
id: "0130b"
title: "Backend Go kanban_cpp v2: schema + handlers + watcher + SSE"
status: pendiente
type: app
domain:
- apps-infra
- dev-ux
scope: app-scoped
priority: alta
depends:
- "0130a"
blocks:
- "0130c"
related:
- "0130"
created: 2026-05-22
updated: 2026-05-22
tags: [service, kanban, go, sqlite, sse]
flow: "0130"
---
# 0130b — Backend Go kanban_cpp v2
**Status:** pendiente
## Por que
Servicio HTTP local que sirve los issues + flows del proyecto al frontend C++. Es un wrapper fino sobre las funciones del registry de 0130a + SQLite cache + watcher.
## Estructura
```
apps/kanban_cpp/backend/
app.md # tag service
go.mod
main.go # entry: flags + run
db.go # open + apply migrations + upsert helpers
handlers.go # endpoints REST
sse_hub.go # broadcaster
watcher.go # bind a watch_dir_fsnotify + re-ingesta + emit SSE
ingest.go # scan → upsert; usa 0130a
migrations/
001_init.sql
operations.db # creada en runtime
```
## Endpoints
| Verbo | Path | Notas |
|---|---|---|
| GET | `/api/health` | `{ok:true, version, count_issues, count_flows}` |
| GET | `/api/issues` | filtros: `status`, `domain`, `priority`, `tag`, `scope` |
| GET | `/api/issues/{id}` | issue + body |
| PATCH | `/api/issues/{id}` | partial update frontmatter → `write_issue_md` + re-ingesta + SSE |
| GET | `/api/flows` | filtros: `status`, `kind` |
| GET | `/api/flows/{id}` | flow + body |
| GET | `/api/meta` | enums leidos de `dev/TAXONOMY.md` |
| GET | `/api/sse` | stream `{type, id, path}` |
CORS abierto local (`*`). Logger middleware.
## Schema (migrations/001_init.sql)
```sql
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
type TEXT,
scope TEXT,
priority TEXT,
domain_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
depends_json TEXT NOT NULL DEFAULT '[]',
blocks_json TEXT NOT NULL DEFAULT '[]',
related_json TEXT NOT NULL DEFAULT '[]',
flow_id TEXT,
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL,
created_at TEXT,
updated_at TEXT,
completed INTEGER NOT NULL DEFAULT 0 -- 1 si vive en completed/
);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
CREATE TABLE IF NOT EXISTS flows (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT,
kind TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mtime_ns INTEGER NOT NULL
);
```
## DoD
- `curl http://localhost:8487/api/health` devuelve 200 + counts.
- `curl http://localhost:8487/api/issues | jq 'length' >= 90`.
- `curl -X PATCH /api/issues/0130 -d '{"status":"in-progress"}'` reescribe `dev/issues/0130-*.md` (status updated, body intacto).
- Despues del PATCH, suscriptor SSE recibe evento `{type:"updated", id:"0130"}`.
- Tras `mv dev/issues/0130-*.md dev/issues/completed/`, watcher actualiza fila (`completed=1`).
- `go test ./...` verde.
## Anti-scope
- No expone proposals ni capabilities (eso es MCP registry).
- No autentica (local-only por ahora).
- No persiste estado UI (eso lo hace el frontend).
@@ -0,0 +1,86 @@
---
id: "0130c"
title: "Frontend C++ ImGui kanban_cpp v2: board + flows + filtros + detalle"
status: pendiente
type: app
domain:
- cpp-stack
- dev-ux
scope: app-scoped
priority: alta
depends:
- "0130b"
blocks: []
related:
- "0130"
created: 2026-05-22
updated: 2026-05-22
tags: [cpp, imgui, kanban, frontend]
flow: "0130"
---
# 0130c — Frontend C++ ImGui kanban_cpp v2
**Status:** pendiente
## Por que
UI nativa sobre el backend 0130b. Aprovecha el framework `fn::run_app` (menubar, layouts, settings, about, log) y los componentes del registry (`data_table`, `kpi_card`, `http_request`, `sse_client`).
## Estructura
```
apps/kanban_cpp/
app.md
appicon.ico
CMakeLists.txt
main.cpp # fn::run_app + cfg.panels
data.h / data.cpp # http client + state global (issues, flows, filters)
panel_board.cpp # 4 columnas + drag-drop
panel_flows.cpp # tabla via data_table_cpp_viz
panel_filters.cpp # Aside con multi-select
panel_detail.cpp # form editable del issue seleccionado
panels.h
```
## Trio obligatorio (`app.md`)
```yaml
description: "Kanban C++ v2 para gestionar dev/issues y dev/flows del registry"
icon:
phosphor: "kanban"
accent: "#a855f7"
```
## Paneles
1. **Board** (`TI_KANBAN " Board"`) — 4 columnas (pendiente / in-progress / bloqueado / completado). Cada card: id + title (trunc 60) + priority badge + first domain chip. Drag-drop con `ImGui::BeginDragDropSource/Target` -> PATCH status.
2. **Flows** (`TI_FLOW " Flows"`) — `data_table_cpp_viz` con columnas id/title/status/kind. Click fila → carga detail.
3. **Filters** (`TI_FUNNEL " Filters"`) — AppShell.Aside-equivalente (panel lateral fijo). Multi-select por domain, scope, priority, tags. Estado local; rebuild request query.
4. **Detail** (`TI_INFO " Detail"`) — modal/panel lateral con form: status (combo), priority (combo), scope (combo), tags (chips editables), depends/blocks (listas), body (read-only multiline).
## HTTP client (data.cpp)
- `fetch_issues(filters)` → GET con query string → parse JSON → vector<Issue>.
- `fetch_flows()` → similar.
- `patch_issue(id, partial)` → PATCH JSON → recibe issue actualizado.
- `subscribe_sse()` thread aparte → push events a queue mutex → consumir en main loop → re-fetch afectados.
Usa `http_request_cpp_core` + `sse_client_cpp_core`. JSON via `nlohmann/json` (ya en cpp/vendor o sacar al header-only).
## DoD
- `cmake --build cpp/build/linux --target kanban_cpp -j` verde.
- `./cpp/build/linux/apps/kanban_cpp/kanban_cpp --self-test` exit 0:
- inicializa contexto ImGui sin display.
- parsea respuesta JSON sintetica.
- no toca red salvo si `--backend http://...` se pasa.
- e2e_checks en `app.md`: build + self_test + backend_health (corre backend en background) + smoke (drag-drop reescribe MD).
- Captura screenshot board con 4 columnas pobladas → guardar en `dod_evidence/board_screenshot.png`.
## Anti-scope
- Sin grafo de dependencias (epic 0130 lo describe como anti-scope v1).
- Sin crear issues nuevos (solo editar existentes).
- Sin edicion de body MD (solo frontmatter).
- Sin syntax highlighting markdown.
@@ -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,235 @@
---
id: "0131"
title: "agents v0.2: control per-agent unified mode + uptime/msg_24h + data_table_cpp_viz + clear/cache actions"
status: pendiente
type: feature
domain:
- agents
- tui
- infra
scope: app
priority: alta
depends:
- "0128"
- "0129"
blocks: []
related: []
created: 2026-05-22
updated: 2026-05-22
tags: [agents_and_robots, agents_dashboard, http, unified-mode, data-table, control]
dod_evidence_schema:
# Backend: agents_and_robots
- id: build_backend
kind: cmd
expected: "cd projects/element_agents/apps/agents_and_robots && go build -tags goolm ./... → exit 0"
required: true
- id: tests_backend
kind: cmd
expected: "cd projects/element_agents/apps/agents_and_robots && go test -tags goolm -count=1 ./internal/api/... → exit 0"
required: true
- id: stop_unified_works
kind: cmd
expected: "POST /agents/test-bot/stop devuelve {status:stopped}; GET /agents/test-bot → running=false en <2s"
required: true
- id: start_unified_works
kind: cmd
expected: "POST /agents/test-bot/start tras stop devuelve {status:started}; GET /agents/test-bot → running=true en <5s"
required: true
- id: restart_unified_works
kind: cmd
expected: "POST /agents/test-bot/restart sobre agente running deja running=true en <8s sin error"
required: true
- id: clear_memory_endpoint
kind: cmd
expected: "POST /agents/test-bot/clear_memory devuelve {status:cleared, messages_deleted:N}; SELECT COUNT(*) FROM messages WHERE agent_id='test-bot' == 0"
required: true
- id: delete_cache_endpoint
kind: cmd
expected: "POST /agents/test-bot/delete_cache devuelve {status:cleared, paths_deleted:[...]}; verificar que crypto.db cache borrado"
required: true
- id: uptime_exposed
kind: cmd
expected: "GET /agents incluye campo uptime_seconds:int >0 para agents running"
required: true
- id: msg_24h_exposed
kind: cmd
expected: "GET /agents incluye campo messages_24h:int (puede ser 0) calculado de tabla messages"
required: true
# Frontend: agents_dashboard
- id: build_frontend
kind: cmd
expected: "cmake --build cpp/build/windows --target agents_dashboard -j → exit 0"
required: true
- id: data_table_cpp_viz_used
kind: cmd
expected: "grep -E 'BeginTable|EndTable' projects/element_agents/apps/agents_dashboard/main.cpp devuelve 0 lineas (migrado a data_table_cpp_viz); grep data_table_cpp_viz app.md uses_functions = 1"
required: true
- id: per_agent_buttons_rendered
kind: screenshot
expected: "Tabla Agents muestra >=5 botones por fila: Start, Stop, Restart, Clear Memory, Delete Cache (puede iconos+tooltip)"
required: true
- id: uptime_visible
kind: screenshot
expected: "Tabla Agents columna uptime muestra valor humanizado (ej 12h, 3d) para agents running"
required: true
- id: msg_24h_visible
kind: screenshot
expected: "Tabla Agents columna msg/24h muestra contador real (no 'instances' como hack)"
required: true
# E2E: pytest
- id: e2e_tests_pass
kind: cmd
expected: "AGENTS_API_KEY=... pytest tests/test_connect_e2e.py → todos PASS (>=20 tests)"
required: true
- id: e2e_control_roundtrip
kind: cmd
expected: "Nuevo test_control_roundtrip: stop → poll running=false → start → poll running=true → restart → poll running=true. Todo dentro de 30s."
required: true
- id: e2e_clear_memory
kind: cmd
expected: "Nuevo test_clear_memory: insert filas en messages → POST /clear_memory → COUNT == 0"
required: true
---
# 0131 — agents v0.2: full per-agent control + data_table + nuevos botones
## Contexto
v0.1 (issues 0128+0129) entrego:
- HTTP API + apikey + TLS + SSE
- C++ frontend con Connection/Agents/Logs/Status feed
- Tabla agents con `running` derivado de backend
**Gaps detectados durante uso real:**
1. **Control individual roto en unified mode** — Manager.Start/Stop esperan PID files por agente; en unified mode no existen → endpoints devuelven errores confusos ("not running" sobre agente que SI corre).
2. **No hay uptime ni msg_24h reales** — backend no expone esos campos. UI muestra `instances` como hack para msg_24h.
3. **Faltan acciones de gestion** — clear memory (mensajes en SQLite), delete cache (crypto E2EE), reset state.
4. **Tabla manual**`ImGui::BeginTable` inline en main.cpp. El registry tiene `data_table_cpp_viz` (funcion canonica). Migrar.
## Scope v0.2
### Backend (`projects/element_agents/apps/agents_and_robots/`)
**1. Control per-agent en unified mode**
Hoy launcher arranca todos los agents como goroutines bajo 1 PID via mode "unified". `Manager.Start/Stop/Restart` actuales solo funcionan en mode multi-process (PID por agente).
Anadir registro de cancel-context por agente en el launcher:
- Por cada agente que arranca como goroutine, guardar `context.CancelFunc` en `Manager.unifiedCancels map[string]context.CancelFunc`.
- `Manager.StopUnifiedAgent(id)` llama cancel del agente especifico.
- `Manager.StartUnifiedAgent(id)` re-arranca solo ese agente sin restart del launcher entero.
- `Manager.RestartUnifiedAgent(id)` = Stop + Start.
Handlers `handleStart/Stop/Restart` autodetectan via `IsUnifiedRunning()` y delegan a las nuevas variantes unified.
**2. Uptime real**
- `Manager.startedAt map[string]time.Time` poblado al arrancar cada goroutine.
- En `AgentStatus.UptimeSeconds`, calcular `time.Since(startedAt[id]).Seconds()` si running, else 0.
- Exponer en `agentResponse` como `uptime_seconds: int`.
**3. Messages_24h**
Cada agent persiste mensajes en su SQLite (`agents/<id>/data/memory.db`). El handler `handleListAgents` debe agregar por agente:
- Abrir DB del agente readonly
- `SELECT COUNT(*) FROM messages WHERE created_at > datetime('now', '-24 hours')`
- Cache 30s para no abrir DB en cada request
Exponer como `messages_24h: int`.
**4. Endpoint `POST /agents/{id}/clear_memory`**
- Stop agent (si running)
- Open agent's memory.db
- `DELETE FROM messages` + `DELETE FROM facts`
- Optionally start back si estaba running (deber `?restart=true` opcional)
- Return `{status:"cleared", messages_deleted:N, facts_deleted:M}`
**5. Endpoint `POST /agents/{id}/delete_cache`**
- Stop agent (si running)
- Delete `agents/<id>/data/crypto/` directory (E2EE cache; agent re-init on next start)
- Delete `agents/<id>/data/cache/*` si existe
- Return `{status:"cleared", paths_deleted:[...]}`
- Optionally start back si estaba running (`?restart=true`)
NOTA: delete_cache fuerza re-verificacion E2EE. El agente debe re-autenticarse via SSSS recovery key on next start. Documentar.
### Frontend (`projects/element_agents/apps/agents_dashboard/`)
**1. Migrar a `data_table_cpp_viz`**
Hoy main.cpp usa `ImGui::BeginTable` inline. Sustituir por `data_table::Table` del registry (funcion `data_table_cpp_viz`). Anadir a `app.md::uses_functions`. Verificar via `fn doctor cpp-apps` que la app pasa de `CANDIDATE` a limpio.
**2. Columnas tabla:**
- id
- status icon (running=green, stopped=gray, disabled=yellow, crashed=red)
- uptime (humanized via `human_duration_secs`)
- msg/24h (numero real, NO instances)
- actions (5 botones agrupados):
- `▶ Start` (disabled si running)
- `⏹ Stop` (disabled si !running)
- `↻ Restart`
- `🧠 Clear Memory` (confirmacion modal)
- `🗑 Delete Cache` (confirmacion modal)
**3. Sort + filter** mantener via data_table_cpp_viz API.
### E2E (`tests/`)
Anadir 7 tests nuevos:
- `test_control_roundtrip` — stop → poll → start → poll → restart → poll. Usa `test-bot`.
- `test_clear_memory` — POST clear_memory, verifica COUNT(*) FROM messages == 0.
- `test_delete_cache` — POST delete_cache, verifica crypto/ borrado.
- `test_uptime_field_present` — /agents response incluye uptime_seconds key
- `test_msg_24h_field_present` — /agents response incluye messages_24h key
- `test_unified_stop_does_not_kill_launcher` — tras stop de 1 agente, otros siguen running.
- `test_clear_memory_requires_apikey` — sin Bearer → 401
## Tareas
### Fase A — Backend (agents_and_robots)
1. Agregar `unifiedCancels map[string]context.CancelFunc` + `startedAt map[string]time.Time` + mutex a `shell/process.Manager`.
2. Hook en `launcher` runtime para registrar/desregistrar cancels al arrancar/parar cada agent goroutine.
3. Implementar `StopUnifiedAgent`, `StartUnifiedAgent`, `RestartUnifiedAgent` (Stop+Start).
4. Refactor handlers `handleStartAgent/Stop/Restart` para autodetect unified vs multi.
5. Anadir `uptime_seconds` y `messages_24h` a `AgentResponse`. Implementar query 24h con cache 30s.
6. Implementar handlers `handleClearMemory`, `handleDeleteCache`.
7. Anadir rutas en `server.go`.
8. Tests Go unit `internal/api/*_test.go`.
### Fase B — Frontend (agents_dashboard)
1. Cambiar `parse_agents` para leer `uptime_seconds` y `messages_24h` del backend.
2. Migrar tabla a `data_table_cpp_viz`. Mantener filter + sort.
3. Anadir 5 botones por fila (Start/Stop/Restart/Clear/Delete).
4. Confirmacion modal para Clear/Delete.
5. Actualizar app.md::uses_functions con `data_table_cpp_viz`.
### Fase C — E2E + verify
1. Anadir 7 pytest tests.
2. Run all e2e from registry venv. >=20 tests pass.
3. Rebuild .exe + redeploy Windows.
4. Visual confirm: botones, uptime, msg_24h.
## Acceptance
- [ ] All 14 DoD items green (cmd + screenshots).
- [ ] >=20 e2e tests passing.
- [ ] App C++ deployed to Windows Desktop, visible buttons + working roundtrip.
- [ ] Backend unit tests pass.
- [ ] No regression: 0128 + 0129 funcionalidad existente intacta (curl smoke del v0.1 sigue green).
## DoD humano
- **Donde**: Windows Desktop → agents_dashboard.exe → tabla Agents.
- **Latencia**: stop → running=false reflected in UI within 2s (via SSE status diff). msg/24h refresh cada 30s ok.
- **Onboarding**: tooltip en boton "Clear Memory" explica que borra mensajes; "Delete Cache" explica que el agente tendra que re-autenticar via SSSS al volver a arrancar.
## Riesgos
- Refactor de Manager unified-mode toca el ciclo de vida del launcher (paso ~7 del create_agent pipeline). Tests existentes deben pasar.
- delete_cache borra crypto store; agente debe poder re-verify via env var `SSSS_RECOVERY_KEY_<NORM>`. Si esa env var no esta, agente queda en estado degradado. Validar antes de borrar.
- data_table_cpp_viz puede tener limites de API que ImGui inline no tiene (sort custom, alignment). Verificar antes de migrar.
+1
View File
@@ -40,6 +40,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu
| [cpp-dashboard-viz](cpp-dashboard-viz.md) | 10 | Primitivas C++ ImGui para dashboards: kpi_card, sparkline, line/bar/scatter/pie/heatmap/histogram, panel containers |
| [agents](agents.md) | 3 | Orquestar agentes Claude headless en git worktrees: launch, cleanup, DoD evidence schema audit |
| [backends](backends.md) | — | Stacks backend (Go net/http+SQLite default, MCP, mautrix, bubbletea, httpx, docker-compose): decision tree + esqueleto canonico + funciones del registry a componer |
| [kanban](kanban.md) | 5 | Parser/writer/scanner/watcher de dev/issues/ y dev/flows/: base del backend kanban_cpp v2 |
## Como anadir grupo
+68
View File
@@ -0,0 +1,68 @@
# kanban — Parser/writer de issues y flows del registry
Cluster de funciones para leer, escribir y vigilar los archivos `dev/issues/*.md` y `dev/flows/*.md`. Base del backend de `kanban_cpp v2` (issue 0130b) y de cualquier herramienta que opere sobre el board de desarrollo.
## Funciones
| ID | Firma corta | Que hace |
|---|---|---|
| `parse_issue_md_go_infra` | `(path) → (Issue, []byte, error)` | Lee un .md de issue, extrae frontmatter YAML + body |
| `write_issue_md_go_infra` | `(path, Issue, body) → error` | Serializa Issue a YAML y reescribe el .md preservando body |
| `scan_issues_dir_go_infra` | `(root) → ([]Issue, error)` | Escanea dev/issues/ + completed/, devuelve todos los Issues ordenados |
| `scan_flows_dir_go_infra` | `(root) → ([]Flow, error)` | Escanea dev/flows/, devuelve todos los Flows ordenados |
| `watch_dir_fsnotify_go_infra` | `(ctx, root) → (<-chan FsEvent, error)` | Watcher recursivo con debounce 200ms, emite FsEvent por cambio |
## Tipos
| ID | Que es |
|---|---|
| `issue_go_infra` | Frontmatter de dev/issues/*.md: id, title, status, domain, priority, depends, blocks… |
| `flow_go_infra` | Frontmatter de dev/flows/*.md: id, name/title, status, kind, tags |
| `fs_event_go_infra` | Evento de watcher: {Path, Op} donde Op ∈ {create, write, remove, rename} |
## Ejemplo canónico — arrancar el backend de kanban_cpp
```go
import "fn-registry/functions/infra"
const (
issuesDir = "/home/lucas/fn_registry/dev/issues"
flowsDir = "/home/lucas/fn_registry/dev/flows"
)
// 1. Carga inicial
issues, _ := infra.ScanIssuesDir(issuesDir)
flows, _ := infra.ScanFlowsDir(flowsDir)
fmt.Printf("%d issues, %d flows cargados\n", len(issues), len(flows))
// 2. Actualizar status in-place
iss, body, _ := infra.ParseIssueMd(issuesDir + "/0130-kanban-cpp-v2.md")
iss.Status = "in-progress"
iss.Updated = "2026-05-22"
infra.WriteIssueMd(iss.FilePath, iss, body)
// 3. Vigilar cambios externos (editor de texto, otro agente)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, _ := infra.WatchDirFsnotify(ctx, issuesDir)
for ev := range ch {
if strings.HasSuffix(ev.Path, ".md") {
updated, _, _ := infra.ParseIssueMd(ev.Path)
cache.Upsert(updated) // invalidar cache SQLite
}
}
```
## Fronteras
- NO incluye markdown rendering del body (eso lo hace el frontend).
- NO valida campos contra TAXONOMY (existe `fn doctor issues`).
- NO crea ni borra archivos de issue (solo lee/escribe los existentes).
- NO incluye endpoints HTTP ni SSE (eso es el backend de la app, issue 0130b).
## Notas
- `parse_issue_md` + `write_issue_md` son el par CRUD atómico. Siempre usarlos juntos.
- `scan_issues_dir` llama a `parse_issue_md` internamente — no reimplementar el walk.
- `watch_dir_fsnotify` emite eventos para cualquier archivo, no solo `.md`. Filtrar por extensión en el consumidor.
- El watcher y el writer pueden producir loops: el writer dispara un evento `write` que el watcher emite. El backend debe ignorar eventos generados por sus propios writes (comparar path + timestamp).
+19
View File
@@ -0,0 +1,19 @@
package infra
// Flow representa el frontmatter de un archivo Markdown de flow en dev/flows/.
// Los campos de runtime (FilePath, MtimeNs) no se serializaran en YAML.
type Flow struct {
ID string `yaml:"id"`
Title string `yaml:"title,omitempty"`
Status string `yaml:"status,omitempty"`
Kind string `yaml:"kind,omitempty"`
Tags []string `yaml:"tags,omitempty"`
// Para flows con formato name/status por separado (ej. hn-top-stories).
Name string `yaml:"name,omitempty"`
Priority string `yaml:"priority,omitempty"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
}
+8
View File
@@ -0,0 +1,8 @@
package infra
// FsEvent representa un evento del watcher de sistema de archivos.
// Op es uno de: "create", "write", "remove", "rename".
type FsEvent struct {
Path string // ruta absoluta del archivo afectado
Op string // "create" | "write" | "remove" | "rename"
}
+25
View File
@@ -0,0 +1,25 @@
package infra
// Issue representa el frontmatter de un archivo Markdown de issue en dev/issues/.
// Los campos de runtime (FilePath, MtimeNs, Completed) no se serialiaran en YAML.
type Issue struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Domain []string `yaml:"domain"`
Scope string `yaml:"scope"`
Priority string `yaml:"priority"`
Depends []string `yaml:"depends"`
Blocks []string `yaml:"blocks"`
Related []string `yaml:"related"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow,omitempty"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
// Campos de runtime — NO se serializan en YAML.
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
Completed bool `yaml:"-"` // true si el archivo vive en dev/issues/completed/
}
+186
View File
@@ -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},
})
}
+151
View File
@@ -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.
+201
View File
@@ -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)
}
})
}
}
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"bytes"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
// Completed se deduce del path (contiene "/completed/").
func ParseIssueMd(path string) (Issue, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
}
fm, body, err := splitFrontmatter(data)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
}
var iss Issue
if err := yaml.Unmarshal(fm, &iss); err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
}
iss.FilePath = path
iss.MtimeNs = info.ModTime().UnixNano()
iss.Completed = strings.Contains(path, "/completed/")
return iss, body, nil
}
// splitFrontmatter divide el contenido en bloque YAML y body.
// Espera formato: "---\n<yaml>\n---\n<body>".
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
sep := []byte("---")
newline := []byte("\n")
// El archivo debe empezar con "---\n"
if !bytes.HasPrefix(data, append(sep, '\n')) {
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
}
// Buscar el segundo "---" (en su propia linea)
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
idx := -1
for i := 0; i <= len(rest)-len(sep); i++ {
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
atLineStart := i == 0 || rest[i-1] == '\n'
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
// El separador debe ir seguido de '\n' o EOF
end := i + len(sep)
if end == len(rest) || rest[end] == '\n' {
idx = i
break
}
}
}
if idx == -1 {
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
}
fm := rest[:idx]
// El body empieza despues del segundo "---\n"
bodyStart := idx + len(sep)
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
bodyStart++
}
body := rest[bodyStart:]
_ = newline
return fm, body, nil
}
+51
View File
@@ -0,0 +1,51 @@
---
name: parse_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ParseIssueMd(path string) (Issue, []byte, error)"
description: "Lee un archivo Markdown de issue (dev/issues/*.md), extrae el frontmatter YAML en un struct Issue y devuelve el body tal como esta en disco. Rellena FilePath, MtimeNs y Completed (deduce de si el path contiene /completed/)."
tags: [issue, parser, frontmatter, yaml, kanban, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "strings", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta absoluta o relativa al archivo .md del issue (ej: dev/issues/0130-kanban-cpp-v2.md)"
output: "Struct Issue con todos los campos del frontmatter, byte slice con el body MD, y error si el archivo no existe o el YAML es invalido"
tested: true
tests:
- "parsea 0130-kanban-cpp-v2 correctamente"
- "completed flag se deduce del path"
- "error en archivo inexistente"
- "fixture preserva campos"
test_file_path: "functions/infra/parse_issue_md_test.go"
file_path: "functions/infra/parse_issue_md.go"
---
## Ejemplo
```go
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil {
log.Fatal(err)
}
fmt.Printf("ID=%s Status=%s Domain=%v\n", iss.ID, iss.Status, iss.Domain)
// body contiene el Markdown despues del segundo ---
```
## Cuando usarla
Cuando necesites leer el frontmatter de un issue del registry para mostrarlo, modificarlo o indexarlo. Usar como base de `scan_issues_dir_go_infra` (que la llama por cada archivo) o cuando necesites acceso al body MD ademas del struct.
## Gotchas
- El body devuelto incluye el `\n` inmediatamente posterior al segundo `---`. No se normaliza.
- Si el archivo tiene un solo `---` (sin segundo delimitador), retorna error. Issues sin frontmatter no son validos.
- `Completed` se infiere del path, no del campo `status` del YAML — un issue con `status: completado` que vive en `dev/issues/` (no en `completed/`) tendra `Completed=false`.
- Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` son `[]string` — si el YAML los omite quedan como `nil`, no slice vacio.
+101
View File
@@ -0,0 +1,101 @@
package infra
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func registryRoot() string {
_, thisFile, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(thisFile), "..", "..")
}
func TestParseIssueMd(t *testing.T) {
root := registryRoot()
t.Run("parsea 0130-kanban-cpp-v2 correctamente", func(t *testing.T) {
path := filepath.Join(root, "dev", "issues", "0130-kanban-cpp-v2.md")
iss, body, err := ParseIssueMd(path)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "0130" {
t.Errorf("ID: got %q, want %q", iss.ID, "0130")
}
if !strings.Contains(iss.Title, "Kanban C++ v2") {
t.Errorf("Title %q does not contain 'Kanban C++ v2'", iss.Title)
}
if iss.Status != "pendiente" {
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
}
if len(iss.Domain) < 3 {
t.Errorf("Domain: got %d items, want >=3: %v", len(iss.Domain), iss.Domain)
}
if iss.FilePath != path {
t.Errorf("FilePath: got %q, want %q", iss.FilePath, path)
}
if iss.MtimeNs == 0 {
t.Error("MtimeNs should be non-zero")
}
if iss.Completed {
t.Error("Completed should be false for non-completed issue")
}
if len(body) == 0 {
t.Error("body should not be empty")
}
})
t.Run("completed flag se deduce del path", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
data, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
completedDir := filepath.Join(t.TempDir(), "completed")
if err := os.MkdirAll(completedDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
completedPath := filepath.Join(completedDir, "9999-fixture.md")
if err := os.WriteFile(completedPath, data, 0644); err != nil {
t.Fatalf("write: %v", err)
}
iss, _, err := ParseIssueMd(completedPath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if !iss.Completed {
t.Error("Completed should be true for path with /completed/")
}
})
t.Run("error en archivo inexistente", func(t *testing.T) {
_, _, err := ParseIssueMd("/nonexistent/path/issue.md")
if err == nil {
t.Error("expected error for nonexistent file")
}
})
t.Run("fixture preserva campos", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
iss, body, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd error: %v", err)
}
if iss.ID != "9999" {
t.Errorf("ID: got %q, want %q", iss.ID, "9999")
}
if iss.Flow != "0001" {
t.Errorf("Flow: got %q, want %q", iss.Flow, "0001")
}
if len(iss.Depends) != 1 || iss.Depends[0] != "0001" {
t.Errorf("Depends: got %v, want [0001]", iss.Depends)
}
if !strings.Contains(string(body), "Este es el body") {
t.Errorf("body should contain fixture text, got: %s", string(body))
}
})
}
+83
View File
@@ -0,0 +1,83 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ScanFlowsDir escanea el directorio root (dev/flows/) y devuelve todos los Flows
// encontrados en *.md directos.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los flows se devuelven ordenados por ID ascendente.
func ScanFlowsDir(root string) ([]Flow, error) {
matches, err := filepath.Glob(filepath.Join(root, "*.md"))
if err != nil {
return nil, fmt.Errorf("scan_flows_dir: glob: %w", err)
}
var flows []Flow
for _, path := range matches {
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") || strings.EqualFold(base, "AGENT_GUIDE.md") {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
f, err := parseFlowMd(path)
if err != nil {
log.Printf("scan_flows_dir: warning: skip %s: %v", path, err)
continue
}
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i].ID < flows[j].ID
})
return flows, nil
}
// parseFlowMd parsea el frontmatter de un archivo dev/flows/*.md en un struct Flow.
func parseFlowMd(path string) (Flow, error) {
data, err := os.ReadFile(path)
if err != nil {
return Flow{}, fmt.Errorf("read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Flow{}, fmt.Errorf("stat %s: %w", path, err)
}
fm, _, err := splitFrontmatter(data)
if err != nil {
return Flow{}, fmt.Errorf("frontmatter %s: %w", path, err)
}
var f Flow
if err := yaml.Unmarshal(fm, &f); err != nil {
return Flow{}, fmt.Errorf("yaml %s: %w", path, err)
}
// Algunos flows usan "name" y no "title" — normalizar
if f.Title == "" && f.Name != "" {
f.Title = f.Name
}
// Algunos flows usan entero como ID en el YAML — yaml.v3 lo convierte a string OK
f.FilePath = path
f.MtimeNs = info.ModTime().UnixNano()
return f, nil
}
+52
View File
@@ -0,0 +1,52 @@
---
name: scan_flows_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanFlowsDir(root string) ([]Flow, error)"
description: "Escanea el directorio dev/flows/ (root) y devuelve todos los Flows encontrados en *.md directos. Skippea INDEX.md, README.md y AGENT_GUIDE.md. Si un archivo falla al parsearse emite warning y continua. Resultado ordenado por ID ascendente."
tags: [flow, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [flow_go_infra]
returns: [flow_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"]
params:
- name: root
desc: "Ruta al directorio dev/flows/ (absoluta o relativa)."
output: "Slice de Flow ordenado por ID asc con FilePath y MtimeNs rellenados. Flows con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 5 flows"
- "flow 0001 esta presente"
- "flows tienen FilePath y MtimeNs"
- "flows ordenados por ID asc"
test_file_path: "functions/infra/scan_flows_dir_test.go"
file_path: "functions/infra/scan_flows_dir.go"
---
## Ejemplo
```go
flows, err := infra.ScanFlowsDir("/home/lucas/fn_registry/dev/flows")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total flows: %d\n", len(flows))
for _, f := range flows {
fmt.Printf(" %s [%s] %s\n", f.ID, f.Status, f.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para cargar el panel Flows. Tambien util para dashboards de estado del proyecto que necesiten listar flujos activos/pendientes.
## Gotchas
- El struct `Flow` tiene campos `Name` y `Title` porque algunos flows del registry usan `name:` y otros `title:` en el frontmatter. `parseFlowMd` normaliza: si `Title` esta vacio pero `Name` no, copia `Name` a `Title`.
- No tiene subdirectorio `completed/` equivalente — todos los flows activos e historicos viven en el mismo directorio raiz.
- La funcion `parseFlowMd` es interna (no exportada). Si necesitas parsear un flow individual, usa directamente `yaml.Unmarshal` o expone una funcion separada.
+66
View File
@@ -0,0 +1,66 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanFlowsDir(t *testing.T) {
root := registryRoot()
flowsDir := filepath.Join(root, "dev", "flows")
t.Run("scan devuelve al menos 5 flows", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
if len(flows) < 5 {
t.Errorf("expected >= 5 flows, got %d", len(flows))
}
})
t.Run("flow 0001 esta presente", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
found := false
for _, f := range flows {
if f.ID == "0001" {
found = true
break
}
}
if !found {
t.Error("flow 0001 not found in scan results")
}
})
t.Run("flows tienen FilePath y MtimeNs", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for _, f := range flows {
if f.FilePath == "" {
t.Errorf("flow %q has empty FilePath", f.ID)
}
if f.MtimeNs == 0 {
t.Errorf("flow %q has zero MtimeNs", f.ID)
}
}
})
t.Run("flows ordenados por ID asc", func(t *testing.T) {
flows, err := ScanFlowsDir(flowsDir)
if err != nil {
t.Fatalf("ScanFlowsDir: %v", err)
}
for i := 1; i < len(flows); i++ {
if flows[i].ID < flows[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, flows[i].ID, flows[i-1].ID)
break
}
}
})
}
+62
View File
@@ -0,0 +1,62 @@
package infra
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
)
// ScanIssuesDir escanea el directorio root (dev/issues/) y devuelve todos los Issues
// encontrados en *.md directos y en completed/*.md.
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
// Los issues se devuelven ordenados por ID ascendente.
func ScanIssuesDir(root string) ([]Issue, error) {
// Verificar que el directorio raiz existe.
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("scan_issues_dir: root dir %s: %w", root, err)
}
var issues []Issue
// Patterns a escanear: archivos directos y completed/
patterns := []string{
filepath.Join(root, "*.md"),
filepath.Join(root, "completed", "*.md"),
}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("scan_issues_dir: glob %s: %w", pattern, err)
}
for _, path := range matches {
// Saltar INDEX.md y README.md
base := filepath.Base(path)
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") {
continue
}
// Verificar que es un archivo regular
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
iss, _, err := ParseIssueMd(path)
if err != nil {
log.Printf("scan_issues_dir: warning: skip %s: %v", path, err)
continue
}
issues = append(issues, iss)
}
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
})
return issues, nil
}
+54
View File
@@ -0,0 +1,54 @@
---
name: scan_issues_dir
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func ScanIssuesDir(root string) ([]Issue, error)"
description: "Escanea el directorio dev/issues/ (root) y devuelve todos los Issues encontrados en *.md directos y en completed/*.md. Si un archivo falla al parsearse emite un warning al log y continua. Resultado ordenado por ID ascendente."
tags: [issue, scanner, frontmatter, yaml, dev-ux, kanban]
uses_functions: [parse_issue_md_go_infra]
uses_types: [issue_go_infra]
returns: [issue_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings"]
params:
- name: root
desc: "Ruta al directorio dev/issues/ (absoluta o relativa). Debe existir o retorna error."
output: "Slice de Issue ordenado por ID asc. Incluye issues de completed/ con Completed=true. Issues con YAML malformado se omiten con warning."
tested: true
tests:
- "scan devuelve al menos 90 issues"
- "issue 0130 esta presente"
- "issues ordenados por ID asc"
- "completed issues tienen Completed=true"
- "directorio inexistente retorna error"
test_file_path: "functions/infra/scan_issues_dir_test.go"
file_path: "functions/infra/scan_issues_dir.go"
---
## Ejemplo
```go
issues, err := infra.ScanIssuesDir("/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Total issues: %d\n", len(issues))
for _, iss := range issues {
fmt.Printf(" %s [%s] %s\n", iss.ID, iss.Status, iss.Title)
}
```
## Cuando usarla
Al arrancar el backend de kanban_cpp para poblar la cache SQLite inicial. Tambien util para cualquier herramienta que necesite un snapshot completo de todos los issues del proyecto (stats, dashboards, fn doctor).
## Gotchas
- Skippea automaticamente `INDEX.md` y `README.md` — no son issues.
- Si `completed/` no existe (no hay issues completados), no retorna error — devuelve los issues directos.
- La ordenacion es lexicografica por ID string, no numerica. `"0099" < "0100"` funciona bien con el formato de 4 digitos del registry.
- Un issue con YAML invalido no aborta el scan entero — solo ese archivo se omite con un `log.Printf` warning. Si necesitas comportamiento strict (abort en primer error), parsea manualmente con `ParseIssueMd`.
+74
View File
@@ -0,0 +1,74 @@
package infra
import (
"path/filepath"
"testing"
)
func TestScanIssuesDir(t *testing.T) {
root := registryRoot()
issuesDir := filepath.Join(root, "dev", "issues")
t.Run("scan devuelve al menos 90 issues", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
if len(issues) < 90 {
t.Errorf("expected >= 90 issues, got %d", len(issues))
}
})
t.Run("issue 0130 esta presente", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
found := false
for _, iss := range issues {
if iss.ID == "0130" {
found = true
break
}
}
if !found {
t.Error("issue 0130 not found in scan results")
}
})
t.Run("issues ordenados por ID asc", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
for i := 1; i < len(issues); i++ {
if issues[i].ID < issues[i-1].ID {
t.Errorf("not sorted at index %d: %q < %q", i, issues[i].ID, issues[i-1].ID)
break
}
}
})
t.Run("completed issues tienen Completed=true", func(t *testing.T) {
issues, err := ScanIssuesDir(issuesDir)
if err != nil {
t.Fatalf("ScanIssuesDir: %v", err)
}
completedCount := 0
for _, iss := range issues {
if iss.Completed {
completedCount++
}
}
if completedCount == 0 {
t.Error("expected at least some completed issues")
}
})
t.Run("directorio inexistente retorna error", func(t *testing.T) {
_, err := ScanIssuesDir("/nonexistent/dev/issues")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
}
+30
View File
@@ -0,0 +1,30 @@
---
id: "9999"
title: "Fixture issue con caracteres especiales: áéíóú & <test>"
status: pendiente
type: app
domain:
- core
- infra
scope: registry-only
priority: alta
depends:
- "0001"
blocks: []
related:
- "0100"
tags: [test, fixture, round-trip]
flow: "0001"
created: 2026-01-01
updated: 2026-05-22
---
# Fixture issue
Este es el body del issue. Contiene caracteres especiales: áéíóú & <test>.
## Sección
Linea con **negrita** y _cursiva_.
Final del body.
+135
View File
@@ -0,0 +1,135 @@
package infra
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
// Cierra el canal cuando ctx.Done() se dispara.
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
}
// Anadir root y todos los subdirectorios recursivamente.
if err := addDirsRecursive(watcher, root); err != nil {
watcher.Close()
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
}
ch := make(chan FsEvent, 64)
go func() {
defer watcher.Close()
defer close(ch)
// Mapa de debounce: path -> (timer, ultimo op)
type pending struct {
timer *time.Timer
op string
}
debounce := make(map[string]*pending)
const debounceDelay = 200 * time.Millisecond
for {
select {
case <-ctx.Done():
// Cancelar todos los timers pendientes antes de salir.
for _, p := range debounce {
p.timer.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
op := fsnotifyOpToString(event.Op)
if op == "" {
continue
}
path := event.Name
// Si el directorio nuevo fue creado, anadirlo al watcher.
if event.Op&fsnotify.Create != 0 {
if info, err := os.Stat(path); err == nil && info.IsDir() {
if err := watcher.Add(path); err != nil {
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
}
}
}
// Debounce: resetear el timer si ya habia uno para este path.
if p, exists := debounce[path]; exists {
p.timer.Stop()
p.op = op
p.timer.Reset(debounceDelay)
} else {
p = &pending{op: op}
p.timer = time.AfterFunc(debounceDelay, func() {
select {
case ch <- FsEvent{Path: path, Op: p.op}:
case <-ctx.Done():
}
delete(debounce, path)
})
debounce[path] = p
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
}
}
}()
return ch, nil
}
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
// Retorna error si root no existe o no es accesible.
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
if _, err := os.Stat(root); err != nil {
return fmt.Errorf("root dir %s: %w", root, err)
}
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // ignora errores de acceso en subdirs
}
if info.IsDir() {
return watcher.Add(path)
}
return nil
})
}
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
func fsnotifyOpToString(op fsnotify.Op) string {
switch {
case op&fsnotify.Create != 0:
return "create"
case op&fsnotify.Write != 0:
return "write"
case op&fsnotify.Remove != 0:
return "remove"
case op&fsnotify.Rename != 0:
return "rename"
default:
return ""
}
}
+61
View File
@@ -0,0 +1,61 @@
---
name: watch_dir_fsnotify
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error)"
description: "Crea un watcher recursivo sobre root y todos sus subdirectorios usando fsnotify. Emite FsEvent al canal con debounce de 200ms por path (multiples eventos del mismo archivo en la ventana = un solo evento con la ultima op). Cierra el canal cuando ctx.Done(). Anade automaticamente nuevos subdirectorios creados en runtime."
tags: [watcher, fsnotify, filesystem, dev-ux, async, kanban]
uses_functions: []
uses_types: [fs_event_go_infra]
returns: [fs_event_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["context", "fmt", "log", "os", "path/filepath", "time", "github.com/fsnotify/fsnotify"]
params:
- name: ctx
desc: "Context para cancelar el watcher. Al cancelar, el canal se cierra limpiamente."
- name: root
desc: "Directorio raiz a vigilar recursivamente. Debe existir o retorna error."
output: "Canal de solo lectura que emite FsEvent por cada cambio detectado (tras debounce). El canal se cierra cuando ctx se cancela o el watcher interno falla."
tested: true
tests:
- "detecta escritura de archivo"
- "canal se cierra cuando ctx cancela"
- "error en directorio inexistente"
- "debounce agrupa multiples escrituras"
test_file_path: "functions/infra/watch_dir_fsnotify_test.go"
file_path: "functions/infra/watch_dir_fsnotify.go"
---
## Ejemplo
```go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := infra.WatchDirFsnotify(ctx, "/home/lucas/fn_registry/dev/issues")
if err != nil {
log.Fatal(err)
}
for ev := range ch {
fmt.Printf("event: op=%s path=%s\n", ev.Op, ev.Path)
// recargar el issue afectado en cache
}
```
## Cuando usarla
En el backend de kanban_cpp para detectar cambios externos en `dev/issues/` y `dev/flows/` (ediciones en el editor de texto del usuario) y propagar via SSE al frontend ImGui. Tambien util para cualquier daemon que necesite invalidar cache ante cambios en disco.
## Gotchas
- **Debounce por path**: si guardas el mismo archivo 5 veces en 200ms (ej. autoguardado del editor), recibes 1 evento, no 5. El `Op` del evento es el de la ultima operacion en la ventana.
- **Subdirectorios dinamicos**: si se crea un subdirectorio nuevo mientras el watcher esta activo, se anade automaticamente al watcher. Los archivos creados dentro del nuevo subdir se detectan.
- **Eventos CHMOD ignorados**: solo se emiten `create`, `write`, `remove`, `rename`. Cambios de permisos no disparan eventos.
- **Canal con buffer 64**: si el consumidor es lento y el buffer se llena, eventos adicionales se bloquean en la goroutine interna. Con debounce 200ms es poco probable en uso normal.
- **No filtra por extension**: emite eventos para cualquier archivo en el arbol, no solo `.md`. El consumidor debe filtrar si solo le interesan ciertos tipos.
- **Linux inotify limit**: en sistemas con muchos subdirectorios, puede alcanzar el limite de `fs.inotify.max_user_watches` (default 8192). Aumentar con `sysctl fs.inotify.max_user_watches=65536` si se observan errores en el log.
+129
View File
@@ -0,0 +1,129 @@
package infra
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestWatchDirFsnotify(t *testing.T) {
t.Run("detecta escritura de archivo", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Dar tiempo al watcher para arrancar
time.Sleep(50 * time.Millisecond)
// Escribir un archivo
testFile := filepath.Join(tmpDir, "test.md")
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Esperar evento (debounce 200ms + margen)
select {
case ev, ok := <-ch:
if !ok {
t.Fatal("channel closed unexpectedly")
}
if ev.Path != testFile {
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
}
if ev.Op != "create" && ev.Op != "write" {
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
}
case <-ctx.Done():
t.Fatal("timeout waiting for fs event")
}
})
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
// Cancelar inmediatamente
cancel()
// El canal debe cerrarse
timeout := time.After(2 * time.Second)
// Drenar cualquier evento pendiente hasta que el canal se cierre
for {
select {
case _, ok := <-ch:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("channel not closed after ctx cancel within 2s")
}
}
})
t.Run("error en directorio inexistente", func(t *testing.T) {
ctx := context.Background()
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
if err == nil {
t.Error("expected error for nonexistent directory")
}
})
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ch, err := WatchDirFsnotify(ctx, tmpDir)
if err != nil {
t.Fatalf("WatchDirFsnotify: %v", err)
}
time.Sleep(50 * time.Millisecond)
testFile := filepath.Join(tmpDir, "debounce.md")
// Escribir 5 veces rapidamente
for i := 0; i < 5; i++ {
_ = os.WriteFile(testFile, []byte("content"), 0644)
time.Sleep(10 * time.Millisecond)
}
// Esperar debounce + margen
time.Sleep(400 * time.Millisecond)
// Debe haber llegado al menos un evento pero no 5
eventCount := 0
drain:
for {
select {
case _, ok := <-ch:
if !ok {
break drain
}
eventCount++
default:
break drain
}
}
if eventCount == 0 {
t.Error("expected at least one debounced event")
}
if eventCount >= 5 {
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
}
})
}
+33
View File
@@ -0,0 +1,33 @@
package infra
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// WriteIssueMd serializa el frontmatter del Issue a YAML y lo escribe en path junto al body.
// El archivo resultante tiene formato: "---\n<yaml>---\n<body>".
// El body se preserva exactamente tal como fue recibido (sin normalizar trailing newlines).
// Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:"-".
func WriteIssueMd(path string, iss Issue, body []byte) error {
var buf bytes.Buffer
yamlBytes, err := yaml.Marshal(iss)
if err != nil {
return fmt.Errorf("write_issue_md: marshal %s: %w", path, err)
}
buf.WriteString("---\n")
buf.Write(yamlBytes)
buf.WriteString("---\n")
buf.Write(body)
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("write_issue_md: write %s: %w", path, err)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
---
name: write_issue_md
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func WriteIssueMd(path string, iss Issue, body []byte) error"
description: "Serializa el frontmatter de un struct Issue a YAML y escribe el archivo Markdown en disco con formato ---\\nyaml---\\nbody. Preserva el body exactamente sin normalizar trailing newlines ni reordenar. Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:\"-\"."
tags: [issue, writer, frontmatter, yaml, dev-ux, kanban]
uses_functions: []
uses_types: [issue_go_infra]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os", "gopkg.in/yaml.v3"]
params:
- name: path
desc: "Ruta de destino del archivo .md (puede ser la misma de la que se leyo para un update in-place)"
- name: iss
desc: "Struct Issue con el frontmatter a serializar. FilePath/MtimeNs/Completed se ignoran en el YAML de salida"
- name: body
desc: "Body MD tal como fue devuelto por ParseIssueMd — se escribe byte a byte sin modificar"
output: "nil en exito, error si el marshal YAML falla o el archivo no se puede escribir"
tested: true
tests:
- "round-trip parse-write-parse preserva struct"
- "archivo resultante empieza con ---"
- "error en path inexistente"
test_file_path: "functions/infra/write_issue_md_test.go"
file_path: "functions/infra/write_issue_md.go"
---
## Ejemplo
```go
// Actualizar status de un issue in-place
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
if err != nil { log.Fatal(err) }
iss.Status = "in-progress"
iss.Updated = "2026-05-22"
if err := infra.WriteIssueMd("dev/issues/0130-kanban-cpp-v2.md", iss, body); err != nil {
log.Fatal(err)
}
```
## Cuando usarla
Cuando el backend de kanban_cpp necesite actualizar el frontmatter de un issue (cambio de status, priority, tags, etc.) sin tocar el body. Siempre usar en par con `parse_issue_md_go_infra`: parse → modificar struct → write.
## Gotchas
- `yaml.Marshal` de v3 puede reordenar campos respecto al original — el orden del YAML de salida sera el orden de declaracion del struct `Issue`, no el del archivo original. Si el orden importa para diff legibilidad, documentarlo.
- El body se escribe byte a byte. Si lo modificas antes de pasar, lo que escribes es lo que queda.
- No hace backup previo. En sistemas con watcher activo, el write dispara un evento `write` en `watch_dir_fsnotify_go_infra` — el backend debe ignorar sus propios writes para no entrar en loop.
+92
View File
@@ -0,0 +1,92 @@
package infra
import (
"os"
"path/filepath"
"testing"
)
func TestWriteIssueMd(t *testing.T) {
root := registryRoot()
t.Run("round-trip parse-write-parse preserva struct", func(t *testing.T) {
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
// Parse original
iss1, body1, err := ParseIssueMd(fixturePath)
if err != nil {
t.Fatalf("ParseIssueMd: %v", err)
}
// Write a TempDir
tmpPath := filepath.Join(t.TempDir(), "issue_roundtrip.md")
if err := WriteIssueMd(tmpPath, iss1, body1); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
// Parse de nuevo
iss2, body2, err := ParseIssueMd(tmpPath)
if err != nil {
t.Fatalf("ParseIssueMd after write: %v", err)
}
// Comparar campos (ignorar FilePath y MtimeNs que son runtime)
if iss1.ID != iss2.ID {
t.Errorf("ID: %q != %q", iss1.ID, iss2.ID)
}
if iss1.Title != iss2.Title {
t.Errorf("Title: %q != %q", iss1.Title, iss2.Title)
}
if iss1.Status != iss2.Status {
t.Errorf("Status: %q != %q", iss1.Status, iss2.Status)
}
if iss1.Flow != iss2.Flow {
t.Errorf("Flow: %q != %q", iss1.Flow, iss2.Flow)
}
if len(iss1.Domain) != len(iss2.Domain) {
t.Errorf("Domain len: %d != %d", len(iss1.Domain), len(iss2.Domain))
}
if len(iss1.Depends) != len(iss2.Depends) {
t.Errorf("Depends len: %d != %d", len(iss1.Depends), len(iss2.Depends))
}
if len(iss1.Tags) != len(iss2.Tags) {
t.Errorf("Tags len: %d != %d", len(iss1.Tags), len(iss2.Tags))
}
// El body debe preservarse exactamente
if string(body1) != string(body2) {
t.Errorf("body mismatch:\ngot: %q\nwant: %q", string(body2), string(body1))
}
})
t.Run("archivo resultante empieza con ---", func(t *testing.T) {
iss := Issue{
ID: "0001",
Title: "Test issue",
Status: "pendiente",
}
tmpPath := filepath.Join(t.TempDir(), "test.md")
if err := WriteIssueMd(tmpPath, iss, []byte("# Body\n")); err != nil {
t.Fatalf("WriteIssueMd: %v", err)
}
data, _ := os.ReadFile(tmpPath)
if len(data) < 4 || string(data[:4]) != "---\n" {
t.Errorf("file should start with '---\\n', got: %q", string(data[:min(10, len(data))]))
}
})
t.Run("error en path inexistente", func(t *testing.T) {
iss := Issue{ID: "0001", Title: "x", Status: "pendiente"}
err := WriteIssueMd("/nonexistent/dir/issue.md", iss, []byte("body"))
if err == nil {
t.Error("expected error writing to nonexistent dir")
}
})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.7.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/marcboeker/go-duckdb v1.8.5
+2
View File
@@ -39,6 +39,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+38
View File
@@ -0,0 +1,38 @@
---
name: flow
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type Flow struct {
ID string `yaml:"id"`
Title string `yaml:"title,omitempty"`
Status string `yaml:"status,omitempty"`
Kind string `yaml:"kind,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Name string `yaml:"name,omitempty"`
Priority string `yaml:"priority,omitempty"`
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
}
description: "Frontmatter YAML de un archivo dev/flows/*.md. Campos de runtime (FilePath, MtimeNs) no se serializan en YAML."
tags: [flow, frontmatter, yaml, kanban, dev-ux, registry]
uses_types: []
file_path: "functions/infra/flow_type.go"
---
## Ejemplo
```go
f := infra.Flow{
ID: "0001",
Name: "hn-top-stories",
Status: "pending",
Tags: []string{"scraping", "news"},
}
```
## Notas
Producido por `scan_flows_dir_go_infra`. Los flows del registry usan campos variados en su frontmatter — el struct cubre el subconjunto comun: id/name/title/status/kind/tags/priority. Campos desconocidos se ignoran silenciosamente por yaml.Unmarshal.
+30
View File
@@ -0,0 +1,30 @@
---
name: fs_event
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type FsEvent struct {
Path string
Op string // "create" | "write" | "remove" | "rename"
}
description: "Evento del watcher de sistema de archivos. Op es uno de: create, write, remove, rename."
tags: [watcher, fsnotify, event, filesystem, kanban, dev-ux]
uses_types: []
file_path: "functions/infra/fs_event_type.go"
---
## Ejemplo
```go
// Recibido desde el canal de watch_dir_fsnotify_go_infra:
ev := infra.FsEvent{
Path: "/home/lucas/fn_registry/dev/issues/0130a-kanban-cpp-v2-parser.md",
Op: "write",
}
```
## Notas
Producido por `watch_dir_fsnotify_go_infra`. El canal emite un evento por archivo afectado tras el debounce de 200ms. Si se producen multiples operaciones sobre el mismo path en la ventana de debounce, se emite solo la ultima operacion.
+51
View File
@@ -0,0 +1,51 @@
---
name: issue
lang: go
domain: infra
version: "0.1.0"
algebraic: product
definition: |
type Issue struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Domain []string `yaml:"domain"`
Scope string `yaml:"scope"`
Priority string `yaml:"priority"`
Depends []string `yaml:"depends"`
Blocks []string `yaml:"blocks"`
Related []string `yaml:"related"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow,omitempty"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
FilePath string `yaml:"-"`
MtimeNs int64 `yaml:"-"`
Completed bool `yaml:"-"`
}
description: "Frontmatter YAML de un archivo dev/issues/*.md. Campos de runtime (FilePath, MtimeNs, Completed) no se serializan en YAML."
tags: [issue, frontmatter, yaml, kanban, dev-ux, registry]
uses_types: []
file_path: "functions/infra/issue_type.go"
---
## Ejemplo
```go
iss := infra.Issue{
ID: "0130",
Title: "Kanban C++ v2",
Status: "pendiente",
Priority: "alta",
Domain: []string{"cpp-stack", "apps-infra"},
Scope: "multi-app",
Tags: []string{"kanban", "cpp"},
Created: "2026-05-22",
Updated: "2026-05-22",
}
```
## Notas
Producido por `parse_issue_md_go_infra`. Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` se deserializan como `[]string` — si el YAML los omite, quedan como slice vacio (no nil). `Completed` se deduce del path (contiene `/completed/`), no del frontmatter.