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