agent-squad-app · substrate · informe verificado en código

Super Skills: arquitectura y plan de inicialización

Mapa completo del subsistema de Super Skills (workflows reutilizables promovidos desde Nova), cómo se registran e invocan hoy, los 5 gaps de readiness verificados con evidencia archivo:línea, y el checklist para garantizar que estén operativos antes de que arranque cualquier workflow.

3 gaps confirmados 2 gaps parciales 14 componentes mapeados 25 · julio · 2026
01

Qué es un Super Skill aquí

Un Super Skill es la copia 1:1 del draft de un plan_draft compuesto por Nova (shape {template, constraints}), promovida con nombre propio a un artefacto reutilizable. No es una Lambda ni un microservicio: es una fila JSONB en Postgres que el motor compila y ejecuta bajo demanda.

Dónde vive
Tabla superskills del substrate (Postgres :5433), migración 0007_superskills.sql. Ciclo de vida: insert, luego archive (soft delete). El contenido nunca se actualiza.
Cómo nace
El usuario guarda un plan propuesto por Nova: POST /api/workspaces/:id/superskills con {draft_id, name}. Se valida contra OPERATION_CATALOG antes del INSERT.
Su input schema
Las claves {{intent.constraints.X}} extraídas del template (constraintKeysOf), menos la denylist gate_timeout_ms. Overrides fuera de esas claves se silencian.
Cómo se ejecuta
POST .../superskills/:skillId/launch fusiona overrides y delega en launchCompiledPlan: crea Intent + Plan + Trace y emite plan.compiled directo al motor, saltando intent.declared a propósito.
Cómo se re-descubre
Los skills promovidos se reinyectan al prompt de Nova (cap 20) para que futuros pedidos en lenguaje natural hagan match_custom contra el registro, nunca contra el texto del LLM.
02

Inventario de componentes

Cada fila incluye entrypoint exacto, inputs, outputs y dependencias. La tabla scrollea horizontal.

ComponenteEntrypointInputsOutputsDependencias
Tabla superskillsdb/substrate/migrations/0007_superskills.sql:23-44CREATE TABLE superskillsDDLid, workspace_id, name, plan JSONB, est_cost_usd, source_draft_id, archived_at; único parcial uq_superskills_source_activePostgres substrate :5433. Sin FK a plan_drafts (deliberado)
Store substrateapps/api/src/substrate/superskills.ts:26-147insertSuperskill · listSuperskills · getSuperskill · archiveSuperskill · constraintKeysOfplan copiado 1:1 del draft; workspace_id, nameSuperskillRow; constraint keys menos denylistvalidatePlanAgainstCatalog antes del INSERT; list/get filtran archived_at IS NULL
Ruta promoteapps/api/src/routes/superskills.ts:57-129POST /api/workspaces/:id/superskills{draft_id, name 3..80, description?} + Bearer201 con skill + constraint_keys/values; 409 already_promoted / draft_not_promotable / invalid_plangetPlanDraft, insertSuperskill. No muta el draft, no emite eventos, sin rate limit
Ruta listapps/api/src/routes/superskills.ts:137-153GET /api/workspaces/:id/superskillsparams uuid + Bearer200 {skills:[...]} con constraint_keys y values pre-llenadoslistSuperskills (solo activos, created_at DESC)
Ruta launchapps/api/src/routes/superskills.ts:171-215POST .../superskills/:skillId/launch{constraints?, declared_by?}; claves fuera del schema se silencian (l.189-194)201 {launched, intent_id, plan_id, trace_id}; 404 / 409 / 502getSuperskill + launchCompiledPlan; rate limit 10/min por workspace (index.ts:144-150)
launchCompiledPlanapps/api/src/substrate/launch-plan.ts:98-144función compartida (superskills + adhoc)template con id skill-<skillId>; constraints fusionadas{intent_id, plan_id, trace_id}; throw invalid_planRe-valida contra catálogo; fuerza human_gate con fallback 'fail'; emite plan.compiled, nunca intent.declared
nova-compose (puro)apps/api/src/substrate/nova-compose.tsinterpretNovaText · buildComposeSystemPrompttexto crudo del LLM + customSkills + forgedOpsNovaOutcome: match / match_custom / plan (máx 16 steps, 4 LLM) / cannot / invalidOPERATION_CATALOG, EVALUATOR_CATALOG, zod. Sin I/O
Ruta composeapps/api/src/routes/compose.ts:68-296POST /api/workspaces/:id/compose (+launch, +discard){request 10..1000, attachments? ≤3}; timeout LLM 80s + 1 reintentofila plan_drafts + 201 {draft_id, estimated_cost_usd, steps, agents}generateLLMText; reinyecta superskills cap 20 fail-soft
Adapter LLM + ModelPlaneapps/api/src/inngest/llm.ts:215-383generateLLMText({modelClass:'compose'})system prompt ~10KB + user prompt{text, usage, provider, reportedCostUsd}Default claude-cli con sesión OAuth (borra ANTHROPIC_API_KEY del child); fallback opt-in LLM_API_FALLBACK
Store plan_draftsapps/api/src/substrate/plan-drafts.ts:24-70insertPlanDraft · getPlanDraft · flipPlanDraftStatusdraft jsonb, status, first_passestados proposed / launched / discarded / rejected / matched; flips optimistasPostgres :5433 vía substrate/db.ts
Motor Inngestapps/api/src/inngest/functions/index.ts (10 funciones)trigger plan.compiled; serve en /api/inngestevento plan.compiledejecución topológica con retry + timeout; gates con waitForEvent; trace.completed al cierreExecutor self-hosted Docker :8288, --poll-interval 5, sdk-url host.docker.internal:4000
Proxies BFF SvelteKitapps/web/src/routes/api/substrate/superskills/*GET/POST /api/substrate/superskills · POST .../launchsesión (gate doble locals.user + accessAuthorized)GET fail-soft 200; launch devuelve traceId/intentId/planIdConsumidores: NovaModal, NovaOfficeDock, SkillLaunchModal
Cliente server-sideapps/web/src/lib/server/substrate.ts:887-1032fetchSuperskills · promoteSuperskill · launchSuperskillenv SUBSTRATE_API_URL/TOKEN/WORKSPACE_IDfetch fail-soft a []; timeouts 2.5s / 8s / 10s / 88sBearer en cada llamada; el browser nunca ve el token
SSR + helpers UIapps/web/src/routes/workflow-library/+page.server.ts:10-20load · defaultSkillName · matchInputReadylocals.accessAuthorized; input del usuario{canLaunch, mySkills} por SSR; nombre default ≤60 charsCatálogo estático LIVE_WORKFLOWS + skills del workspace
03

Cadena de invocación end-to-end

Browser
Usuario
/workflow-library o Nova (SkillLaunchModal)
Vercel
BFF SvelteKit
POST /api/substrate/superskills/launch · gate de sesión
Hetzner API
Ruta launch
Bearer + rate limit 10/min · merge de overrides
Hetzner API
launchCompiledPlan
re-valida + human_gate · Intent + Plan + Trace
Evento
plan.compiled
inngest.send directo, salta intent.declared
Inngest :8288
execute-plan
concurrency 2, retries 0 · steps topológicos + gates
Inngest
trace.completed
completeTrace + verdict + cost_actual
Notify
notify-dispatch
solo-ops: todo a NOTIFY_EMAIL_OPS
browser vercel api hono evento inngest notify
  1. El usuario lanza desde /workflow-library (SSR) o desde Nova; el browser hace POST /api/substrate/superskills/launch.
  2. El proxy SvelteKit exige sesión (gate doble, 403 si falta) y reenvía con Authorization: Bearer SUBSTRATE_API_TOKEN, timeout 10s.
  3. La ruta Hono carga el skill activo, fusiona skill.plan.constraints con los overrides permitidos y silencia el resto.
  4. launchCompiledPlan re-valida el template como skill-<skillId>, fuerza el contrato de human_gate (fallback 'fail', gate terminal) y borra gate_timeout_ms.
  5. Se crean Intent (subject_label 'superskill'), Plan compilado (sustituye {{intent.constraints.X}}) y Trace en queued.
  6. Se emite plan.compiled y la ruta responde 201 síncrono con los tres ids; la escena de la UI reconcilia por trace_id.
  7. El executor self-hosted despacha substrate-execute-plan: steps con retry + timeout, dos fases en step_executions, gates suspendidos con waitForEvent.
  8. Al cerrar: completeTrace, intent a succeeded/failed, evento trace.completed, y notify despacha (hoy: solo a operaciones).
04

Cómo se registran y descubren hoy

Funciones Inngest

Las 10 funciones viven en FUNCTIONS (functions/index.ts) y se sirven en /api/inngest, fuera del bearer. El executor las descubre por polling cada 5 segundos (--poll-interval 5 en el docker-compose), no por push: el PUT /api/inngest existe pero el auto-deploy no lo ejecuta. El guard de INNGEST_SERVE_HOST es fatal en prod pero valida topología estática, no registro real. La única prueba del path completo es el canary del cron slo-alert, cada 10 minutos.

Skills por workspace

El descubrimiento es una consulta a la DB: GET .../superskills lista activos con sus constraint_keys. La UI los recibe por SSR (fail-soft a lista vacía) y cada compose los reinyecta al prompt de Nova (cap 20): si Nova matchea custom:<uuid>, el server valida el uuid contra el registro, nunca confía en el texto del LLM.

Límites

Launch: 10/min por workspace. Intents clásicos: 10/min global. Promote y list sin rate limit propio. Todo /api/workspaces/* detrás del bearer compartido.

05

Gaps de inicialización, verificados en código

PARCIAL
(a) El auto-deploy no re-sincroniza ni verifica el registro de funciones tras el restart
Evidenciaauto-deploy-api.sh:49-57 gatea solo con curl /health 200 y actualiza el marker; jamás hace PUT a /api/inngest ni consulta al executor. Mitigado por el polling de 5s del executor.
ImpactoEn el caso infeliz (executor caído, signing key rotada) el deploy se declara exitoso con el motor async muerto; el primer aviso llega hasta 10 minutos después por el canary. La misma clase de silencio del incidente serveHost=localhost.
FixTras el health: PUT /api/inngest + verificar contra la API del executor que las 10 funciones quedaron registradas antes de actualizar el marker.
CONFIRMADO
(b) /health valida DB y tracing pero no Inngest ni el schema
Evidenciaroutes/health.ts:9-39: checks = SELECT 1 + tracing. Ningún fetch a INNGEST_BASE_URL, ningún to_regclass de superskills. Agravante: migraciones repartidas en dos directorios; un bootstrap parcial es invisible.
ImpactoEl semáforo verde certifica un API que puede tener el motor muerto o la tabla superskills inexistente: promote/launch fallan con 500 o dan 201 sin ejecución.
FixNuevo GET /ready compuesto: DB + schema + Inngest alcanzable + conteo de funciones registradas. Ese endpoint gatea el deploy.
CONFIRMADO
(c) Sin validación al boot de env vars críticas
Evidenciaenv.ts: INNGEST_EVENT_KEY (l.8), INNGEST_SIGNING_KEY (l.9), ANTHROPIC_API_KEY (l.24), SUBSTRATE_API_TOKEN (l.32) y NOTIFY_EMAIL_OPS (l.117) son todos optional; solo SUBSTRATE_DB_URL es obligatoria. Los guards fatales cubren solo serve-host y tracing.
ImpactoUn .env incompleto pasa el boot: el launch revienta recién en el primer inngest.send, y sin NOTIFY_EMAIL_OPS los trace_failed se descartan en silencio (viola la regla solo-ops).
FixassertProdEnvComplete() junto a los guards existentes: Inngest keys + token + email ops + al menos un path LLM viable.
PARCIAL
(d) Launch con Inngest caído da 502 correcto pero deja huérfanos; con función sin registrar da 201 fantasma
Evidencialaunch-plan.ts:118-141: Intent 'running', plan y trace 'queued' se crean ANTES del send; si el send falla no hay rollback y el reaper no los caza (solo caza step_executions). Si el executor vive pero la función no está registrada, el send acepta el evento y la ruta responde 201.
ImpactoIntents y traces fantasma en 'running' que ensucian métricas y UI; el usuario recibe confirmación de un lanzamiento que el executor no conoce. Silencio hasta el canary.
FixCompensación try/catch alrededor del send (intent y trace a failed) + gate engine_not_ready 503 en el endpoint de launch.
CONFIRMADO
(e) Sin orden de arranque garantizado entre Postgres, Inngest y el API
Evidenciaagent-squad-api.service:3-4 ordena contra el daemon Docker (After=docker.service), no contra los contenedores substrate-postgres (:5433) ni substrate-inngest (:8288). La conexión Postgres es lazy y el boot no la prueba.
ImpactoEn un reboot del box el API queda listening antes que sus dependencias: los primeros requests fallan, systemd considera el servicio activo y la recuperación es por reintentos del cliente, no por diseño.
FixExecStartPre con espera activa de pg_isready :5433 y TCP :8288, más un SELECT 1 fail-fast antes del banner de listening.
06

Checklist: superskills activos antes de cualquier workflow

12 pasos ordenados. Marca los completados (se guardan en este navegador). Filtra por fase.

0/12
BootValidar env completo en prod: assertProdEnvComplete() junto a los guards de index.ts:64-65, exigiendo INNGEST_EVENT_KEY + INNGEST_SIGNING_KEY, SUBSTRATE_API_TOKEN, NOTIFY_EMAIL_OPS y un path LLM viable.Cierra el gap (c)
BootVerificar schema al arrancar: SELECT to_regclass(...) para superskills, plan_drafts, intents, plans, traces, step_executions; fatal si falta alguna.Cubre los dos directorios de migraciones, gap (b)
BootFail-fast de DB: un SELECT 1 real antes del banner de listening (hoy la conexión es lazy).Complemento del gap (e)
systemdOrden de arranque: ExecStartPre con espera activa de pg_isready :5433 y TCP :8288, acotado por TimeoutStartSec.Cierra el gap (e)
DeployRe-sync Inngest post-restart: curl -X PUT http://127.0.0.1:4000/api/inngest en auto-deploy-api.sh, sin depender solo del polling de 5s.Primera mitad del gap (a)
DeployVerificar registro real: consultar la API del executor (:8288) y comprobar que las 10 funciones de FUNCTIONS figuran; si no coincide, no actualizar el marker.Segunda mitad del gap (a)
APIReadiness compuesto: nuevo GET /ready con DB + schema + Inngest alcanzable + conteo de funciones + env crítico, separado del /health barato.Cierra el gap (b)
DeployGatear el marker del deploy con /ready, no con /health.Conecta (a) y (b)
DeployWarm-up: disparar el canary existente inmediatamente post-deploy y exigir su round-trip antes de declarar el deploy OK, sin esperar el cron de 10 min.Detección inmediata del path Inngest→SDK
LaunchCompensación de huérfanos: try/catch alrededor del inngest.send en launch-plan.ts que marca intent y trace como failed si el send revienta.Primera mitad del gap (d)
LaunchGate de registro en el launch: verificar con cache corto que substrate-execute-plan está registrada; si no, responder 503 engine_not_ready en vez del 201 fantasma.Segunda mitad del gap (d)
ContinuoMantener el canary cada 10 min como segunda línea de detección, ya no como la única.Ya existe: slo-alert.ts:119-128
Sin pasos en este filtro.
07

Snippets listos para adaptar

Rutas y nombres reales del repo. El endpoint exacto de la API del executor Inngest self-hosted queda por confirmar contra la versión desplegada.

(a) Readiness endpoint compuesto (DB + schema + Inngest + env) en Hono
// apps/api/src/routes/ready.ts (montar en index.ts junto a healthRoute)
import { Hono } from 'hono';
import { sql } from '../substrate/db';
import { env } from '../env';
import { FUNCTIONS } from '../inngest/functions'; // las 10 funciones

const REQUIRED_TABLES = ['superskills', 'plan_drafts', 'intents', 'plans', 'traces', 'step_executions'];

export const readyRoute = new Hono().get('/ready', async (c) => {
  const checks: Record<string, boolean | string> = {};

  // 1. DB viva
  try { await sql`SELECT 1 AS ok`; checks.substrate_db = true; }
  catch { checks.substrate_db = false; }

  // 2. Schema aplicado (cubre los DOS directorios de migraciones)
  try {
    const rows = await sql`
      SELECT unnest(${REQUIRED_TABLES}::text[]) AS t,
             to_regclass('public.' || unnest(${REQUIRED_TABLES}::text[])) IS NOT NULL AS present`;
    const missing = rows.filter(r => !r.present).map(r => r.t);
    checks.schema = missing.length === 0 ? true : `missing: ${missing.join(',')}`;
  } catch { checks.schema = false; }

  // 3. Executor Inngest alcanzable + funciones registradas
  try {
    const base = env.INNGEST_BASE_URL; // http://127.0.0.1:8288
    const ping = await fetch(base, { signal: AbortSignal.timeout(2000) });
    checks.inngest_reachable = ping.ok;
    // endpoint por confirmar segun version del executor (p.ej. /v1/apps)
    const apps = await fetch(`${base}/v1/apps`, { signal: AbortSignal.timeout(2000) }).then(r => r.json());
    const registered = apps?.data?.find((a) => a.name?.includes('substrate'))?.functions_count ?? 0;
    checks.inngest_functions = registered >= FUNCTIONS.length ? true : `registered ${registered}/${FUNCTIONS.length}`;
  } catch { checks.inngest_reachable = false; }

  // 4. Env critico (espejo de assertProdEnvComplete)
  checks.env = Boolean(env.INNGEST_EVENT_KEY && env.SUBSTRATE_API_TOKEN && env.NOTIFY_EMAIL_OPS)
    || 'missing critical env';

  const ready = Object.values(checks).every(v => v === true);
  return c.json({ ready, checks }, ready ? 200 : 503);
});
(b) systemd + deploy con re-sync y gate de readiness
# /etc/systemd/system/agent-squad-api.service.d/robustness.conf (extender el drop-in existente)
[Service]
# Espera activa a las dependencias reales, no solo al daemon Docker (gap e)
ExecStartPre=/bin/sh -c 'until pg_isready -q -h 127.0.0.1 -p 5433; do sleep 2; done'
ExecStartPre=/bin/sh -c 'until (echo > /dev/tcp/127.0.0.1/8288) 2>/dev/null; do sleep 2; done'
TimeoutStartSec=180

# substrate-infra/scripts/auto-deploy-api.sh (reemplaza el bloque de las lineas 49-57)
systemctl restart agent-squad-api.service

# 1. Gate de readiness compuesto (NO el /health barato)
for i in $(seq 1 30); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4000/ready) && [ "$code" = "200" ] && break
  sleep 2
done
[ "$code" = "200" ] || { echo "ERROR: /ready=$code, marker NO actualizado" >&2; exit 1; }

# 2. Forzar re-registro en el executor (no depender del polling de 5s)
curl -sf -X PUT http://127.0.0.1:4000/api/inngest > /dev/null \
  || { echo "ERROR: PUT /api/inngest fallo" >&2; exit 1; }

# 3. Verificar registro real de las 10 funciones (endpoint por confirmar)
fns=$(curl -sf http://127.0.0.1:8288/v1/apps | jq '[.data[] | select(.name|test("substrate")) | .functions_count] | add // 0')
[ "$fns" -ge 10 ] || { echo "ERROR: solo $fns/10 funciones registradas" >&2; exit 1; }

# 4. Smoke: disparar el canary ya, sin esperar el cron de 10 min (ruta por confirmar)
curl -sf -X POST http://127.0.0.1:4000/api/dev/canary-fire > /dev/null || true

# 5. Solo ahora, actualizar el marker
git -C "$REPO" rev-parse HEAD > "$MARKER"
(c) Guard en el launch: verificar registro de la función antes de aceptar
// apps/api/src/substrate/engine-ready.ts (guard con cache corto para no penalizar cada launch)
let cache = { ok: false, at: 0 };
const TTL_MS = 15_000;

export async function assertExecutePlanRegistered(): Promise<void> {
  if (Date.now() - cache.at < TTL_MS && cache.ok) return;
  try {
    // endpoint por confirmar segun version del executor self-hosted
    const res = await fetch(`${env.INNGEST_BASE_URL}/v1/apps`, { signal: AbortSignal.timeout(1500) });
    const apps = await res.json();
    const fns = apps?.data?.flatMap((a) => a.functions?.map((f) => f.id) ?? []) ?? [];
    cache = { ok: fns.some((id) => id.includes('substrate-execute-plan')), at: Date.now() };
  } catch {
    cache = { ok: false, at: Date.now() };
  }
  if (!cache.ok) throw new Error('engine_not_ready');
}

// apps/api/src/routes/superskills.ts (dentro del handler de launch, ANTES de crear nada)
try {
  await assertExecutePlanRegistered();
} catch {
  return c.json({ error: 'engine_not_ready' }, 503); // en vez del 201 fantasma del gap (d)
}
// ... getSuperskill + merge de overrides + launchCompiledPlan como hoy ...

// Y en launch-plan.ts, la compensacion (mitad 1 del gap d):
try {
  await inngest.send({ name: 'plan.compiled', data: { intent_id, plan_id, template_id, workspace_id } });
} catch (err) {
  await updateIntentStatus(intent.id, 'failed');
  await completeTrace(trace.id, { verdict: 'failed', reason: 'event_send_failed' }); // firma real por confirmar
  throw err; // la ruta lo mapea a 502 launch_failed como hoy, pero ya sin huerfanos 'running'
}