# Informe de arquitectura: Super Skills (agent-squad-app)

> Generado: 2026-07-25 · Verificado en código (6 lectores + cazador de gaps) · Versión interactiva: https://playgrounds.digitalhubassist.ai/superskills-arquitectura.html

## 1. 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. Vive como fila JSONB en la tabla Postgres `superskills` del substrate (puerto 5433, migración `db/substrate/migrations/0007_superskills.sql`), con ciclo de vida insert → archive (soft delete, sin UPDATE de contenido). Nace cuando el usuario guarda un plan propuesto por Nova vía `POST /api/workspaces/:id/superskills` con `{draft_id, name}` (`apps/api/src/routes/superskills.ts:57-129`), que valida el plan contra `OPERATION_CATALOG` antes del INSERT. Su "input schema" son las claves `{{intent.constraints.X}}` extraídas del template (`constraintKeysOf`) menos la denylist `gate_timeout_ms`. Se ejecuta con `POST /api/workspaces/:id/superskills/:skillId/launch`, que fusiona overrides permitidos y delega en `launchCompiledPlan` (`apps/api/src/substrate/launch-plan.ts:98-144`): crea Intent + Plan compilado + Trace y emite el evento Inngest `plan.compiled` directo al motor, saltando `intent.declared` a propósito. Los skills promovidos se reinyectan al prompt de Nova (cap 20) para que futuros pedidos hagan `match_custom`.

## 2. Inventario de componentes

| Componente | Archivo | Entrypoint | Inputs | Outputs | Dependencias |
|---|---|---|---|---|---|
| Tabla `superskills` (DDL) | `db/substrate/migrations/0007_superskills.sql:23-44` | `CREATE TABLE superskills` | ninguno | Columnas id, workspace_id, name, description, plan JSONB, est_cost_usd, source_draft_id, created_at, archived_at; índices `idx_superskills_workspace` y único parcial `uq_superskills_source_active` | Postgres substrate:5433; sin FK a plan_drafts (deliberado) |
| Store substrate de superskills | `apps/api/src/substrate/superskills.ts:26-147` | `insertSuperskill` (l.47), `listSuperskills` (l.85), `getSuperskill` (l.104), `archiveSuperskill` (l.126), `constraintKeysOf` (l.144) | plan `{template, constraints}` copiado 1:1 del draft; workspace_id, name | `SuperskillRow`; `constraintKeysOf` = claves de constraint menos denylist `gate_timeout_ms` | `validatePlanAgainstCatalog` de `@agent-squad/substrate-spec` ANTES del INSERT; `extractConstraintKeys` de nova-compose; list/get filtran `archived_at IS NULL` |
| Ruta promote | `apps/api/src/routes/superskills.ts:57-129` | `POST /api/workspaces/:id/superskills` | `{draft_id uuid, name 3..80, description? ≤500}`; Bearer `SUBSTRATE_API_TOKEN` | 201 con skill plano + constraint_keys/values; 409 `already_promoted` (SELECT previo + catch 23505), `draft_not_promotable`, `invalid_plan` | `getPlanDraft` (plan-drafts.ts), `insertSuperskill`; NO muta el draft, NO emite eventos, sin rate limit |
| Ruta list | `apps/api/src/routes/superskills.ts:137-153` | `GET /api/workspaces/:id/superskills` | Params uuid; Bearer | 200 `{skills:[...]}` con constraint_keys y constraint_values pre-llenados | `listSuperskills` (solo activos, `created_at DESC`) |
| Ruta launch | `apps/api/src/routes/superskills.ts:171-215` | `POST /api/workspaces/:id/superskills/:skillId/launch` | `{constraints? (solo claves en constraintKeysOf, resto SILENCIADO l.189-194), declared_by? (default 'user:anonymous')}` | 201 `{launched, intent_id, plan_id, trace_id}`; 404 `skill_not_found`; 409 `invalid_skill`; 502 `launch_failed` | `getSuperskill`, `launchCompiledPlan`; rate limit 10/min por workspace (`apps/api/src/index.ts:111,144-150`); montaje en `index.ts:207` |
| `launchCompiledPlan` (maquinaria compartida) | `apps/api/src/substrate/launch-plan.ts:98-144` (gate l.69-96, denylist l.27) | `launchCompiledPlan({workspaceId, subjectRef, subjectLabel, templateId, template, constraints, declaredBy})` | template con id reescrito a `skill-<skillId>` (o `adhoc-<draftId>`); constraints fusionadas | `{intent_id, plan_id, trace_id}`; throw `invalid_plan` si falla re-validación o contrato human_gate (fallback 'fail', gate terminal) | intents/plans/traces; `compilePlanFromTemplate` (plans.ts:18-119); emite `plan.compiled` `{intent_id, plan_id, template_id, workspace_id}` (l.133-141), nunca `intent.declared` |
| nova-compose (módulo puro) | `apps/api/src/substrate/nova-compose.ts` (prompt 522-641, parser 688-698, Zod 707-733, `interpretNovaText` 829-1098, `extractConstraintKeys` 808-816) | `buildComposeSystemPrompt` / `buildComposeUserPrompt` / `interpretNovaText` | texto crudo del LLM + customSkills del workspace + forgedOps | `NovaOutcome`: match / match_custom / plan (template enriquecido, máx 16 steps, 4 LLM) / cannot / invalid | `OPERATION_CATALOG`, `EVALUATOR_CATALOG`, zod; sin I/O |
| Ruta compose (origen del draft) | `apps/api/src/routes/compose.ts:68-296` (launch 306-358, discard 361-369) | `POST /api/workspaces/:id/compose` (+ `/:draftId/launch`, `/:draftId/discard`) | `{request 10..1000, attachments? ≤3}`; timeout LLM 80s + 1 reintento con feedback | fila `plan_drafts` (proposed/matched/rejected) + 201 `{draft_id, estimated_cost_usd, steps, agents}` | `generateLLMText`, plan-drafts, launch-plan, `listSuperskills` cap 20 fail-soft; montaje `index.ts:203` |
| Adapter LLM + ModelPlane | `apps/api/src/inngest/llm.ts:215-254` (CLI 274-383, cliEnv 261-272) + `apps/api/src/model-plane/policy.ts:19-27`, `resolve.ts:54-76` | `generateLLMText({modelClass:'compose',...})` / `resolveModelClass` | system prompt ~10KB + user prompt | `{text, usage, provider, reportedCostUsd}` | Default `claude-cli` + sesión OAuth Max (borra `ANTHROPIC_API_KEY` del child); fallback opt-in `LLM_API_FALLBACK=true`; precedencia env > `model_routing_policy` (DB) > default, fail-open |
| Store `plan_drafts` | `apps/api/src/substrate/plan-drafts.ts:24-70`; DDL 0005/0008 | `insertPlanDraft` / `getPlanDraft` / `flipPlanDraftStatus` | draft jsonb, status, first_pass | fila con estados proposed/launched/discarded/rejected/matched; flips optimistas (WHERE status=from) | Postgres 5433 vía `substrate/db.ts` |
| Motor Inngest (`substrate-execute-plan` + serve) | funciones registradas en `apps/api/src/inngest/functions/index.ts` (10 FUNCTIONS); ruta del archivo execute-plan por confirmar | trigger `plan.compiled`, concurrency 2, retries 0; serve handler en `/api/inngest` (fuera del bearer) | evento `plan.compiled` | ejecución topológica de steps con `runWithRetry` + `runStepWithTimeout`, dos fases en `step_executions`; gates con `step.waitForEvent` (`approval.received` / `task.completed`); `completeTrace` + `trace.completed` → `substrate-notify-dispatch` | Executor self-hosted Docker (127.0.0.1:8288) con `--sdk-url http://host.docker.internal:4000/api/inngest --poll-interval 5` |
| Proxies BFF SvelteKit | `apps/web/src/routes/api/substrate/superskills/+server.ts:14-44` y `superskills/launch/+server.ts:14-34` | GET/POST `/api/substrate/superskills`, POST `/api/substrate/superskills/launch` | sesión (gate doble `locals.user` + `locals.accessAuthorized`); JSON con draft_id/name o skillId/constraints | GET fail-soft 200 `{skills}`; POST propaga status del motor; launch devuelve traceId/intentId/planId | `$lib/server/substrate`; consumidores: `NovaModal.svelte:182,233`, `NovaOfficeDock.svelte:455`, `SkillLaunchModal.svelte:51` |
| Cliente server-side del motor | `apps/web/src/lib/server/substrate.ts` (`readSubstrateConfig` 19-31, compose 702-757, `fetchSuperskills` 932-954, `promoteSuperskill` 887-922, `launchSuperskill` 1000-1032) | funciones homónimas | env `SUBSTRATE_API_URL/TOKEN/WORKSPACE_ID`; payloads de cada op | fetch fail-soft a `[]`; promote/launch propagan status; timeouts 2.5s/8s/10s/88s (compose) | Bearer en cada llamada; el browser nunca ve token ni baseUrl |
| SSR + helpers UI | `apps/web/src/routes/workflow-library/+page.server.ts:10-20`; `apps/web/src/lib/superskills/defaultName.ts:18-28`; `matchInput.ts:10,20-27` | `load` (PageServerLoad), `defaultSkillName`, `matchInputReady` | `locals.accessAuthorized`; request del usuario; `LiveWorkflowMeta` | `{canLaunch, mySkills}` por SSR; nombre default ≤60 chars; gate client-side de longitud/formato (NO matching semántico) | `fetchSuperskills`; catálogo estático `LIVE_WORKFLOWS` (`$lib/library/launchable.ts:19-26`) |

## 3. Cadena de invocación end-to-end

1. **Usuario**: en `/workflow-library` (skills listados por SSR) o en Nova (`NovaModal` / `NovaOfficeDock`); para un skill custom o de la library pulsa lanzar (`SkillLaunchModal.svelte:51`, `NovaModal.svelte:182`, `NovaOfficeDock.svelte:455`).
2. **UI → BFF**: el browser hace `POST /api/substrate/superskills/launch` con `{skillId, constraints?}`; el proxy SvelteKit (`apps/web/src/routes/api/substrate/superskills/launch/+server.ts:14-34`) exige sesión (gate doble `locals.user` + `locals.accessAuthorized`, 403 si falta).
3. **BFF → API**: `launchSuperskill` (`substrate.ts:1000-1032`) hace `POST ${SUBSTRATE_API_URL}/api/workspaces/${SUBSTRATE_WORKSPACE_ID}/superskills/${skillId}/launch` con `Authorization: Bearer ${SUBSTRATE_API_TOKEN}`, timeout 10s.
4. **API Hono**: `superskillsRoute.post(...launch)` (`routes/superskills.ts:171-215`), tras `protectExposed` (`index.ts:77-78`) y rate limit 10/min por workspace (`index.ts:144-150`), carga el skill activo con `getSuperskill` y fusiona `skill.plan.constraints` + overrides permitidos (`constraintKeysOf`; claves no permitidas se silencian).
5. **launchCompiledPlan** (`launch-plan.ts:98-144`): re-valida el template contra `OPERATION_CATALOG` con id `skill-<skillId>`, fuerza `assertHumanGatePresent` (≥1 `human_gate.approve`, todos con fallback `'fail'`, ≥1 gate terminal) y borra `gate_timeout_ms` (denylist).
6. **Intent + Plan**: `createIntent` (kind `execute_action`, subject_label `'superskill'`, subject_ref skillId, acceptance_criteria_ref `eval.intent.nova_adhoc@1`) → status `planning` → `compilePlanFromTemplate` (`plans.ts:18-119`, sustituye `{{intent.constraints.X}}`) escribe filas en `plans`, `steps`, `plan_edges`.
7. **Trace**: `createTrace` (status `queued`) → intent pasa a `running`.
8. **Evento**: `inngest.send('plan.compiled', {intent_id, plan_id, template_id, workspace_id})` DIRECTO, saltando `intent.declared` (el default-throw de `handle-intent-declared`, que no conoce `'superskill'`, actúa de guard). La ruta responde 201 síncrono y el BFF mapea `{trace_id, intent_id, plan_id}` a `{traceId, intentId, planId}` para reconciliar la escena.
9. **Inngest**: el executor self-hosted (Docker, 127.0.0.1:8288, sdk-url `host.docker.internal:4000/api/inngest`) despacha a `substrate-execute-plan` (trigger `plan.compiled`, concurrency 2, retries 0).
10. **Steps**: execute-plan carga el plan compilado, ordena topológicamente y ejecuta cada step con `runWithRetry` + `runStepWithTimeout` (presupuesto `steps.timeout_ms`), ciclo de vida en dos fases sobre `step_executions`; los human gates suspenden con `step.waitForEvent` (`approval.received` filtrado por artifact_id con `gate.timeout_ms`; `human_task` espera `task.completed` por step_execution_id, fallback siempre fail).
11. **Cierre**: `completeTrace` fija verdict y cost_actual en `traces`, el intent pasa a `succeeded`/`failed` y se emite `trace.completed`.
12. **Notify**: `substrate-notify-dispatch` consume `trace.completed` y despacha la notificación.

## 4. Cómo se registran y descubren hoy

**Registro de funciones Inngest.** Las 10 funciones viven en `FUNCTIONS` (`apps/api/src/inngest/functions/index.ts`) y se sirven por el serve handler de Hono en `/api/inngest`, deliberadamente fuera del bearer. El executor Inngest self-hosted (Docker) las descubre por polling: corre con `--sdk-url http://host.docker.internal:4000/api/inngest` y `--poll-interval 5` (`substrate-infra/inngest/docker-compose.yml:59-60,69-70`), es decir re-lee la topología cada 5 segundos sin necesidad de un `PUT /api/inngest` explícito. El `PUT` de sync existe como mecanismo pero el auto-deploy no lo ejecuta (`substrate-infra/scripts/auto-deploy-api.sh:49-57` solo cURLea `/health`). El guard `inngest-serve-guard.ts:38-43` valida `INNGEST_SERVE_HOST` al boot (fatal en prod), pero valida topología estática, no registro real. La única verificación del path completo es el canary (`apps/api/src/inngest/functions/canary.ts:13-20` + `scripts/slo-alert.ts:119-128`, cron cada 10 minutos).

**Descubrimiento de skills por workspace (DB).** `GET /api/workspaces/:id/superskills` lista los activos (`archived_at IS NULL`, `created_at DESC`) con `constraint_keys` y `constraint_values`. La UI los recibe por SSR en el load de `/workflow-library` (`+page.server.ts:14`, fail-soft a lista vacía). Además, cada compose reinyecta los skills custom al prompt de Nova (cap 20, fail-soft): si Nova responde `match` con `custom:<uuid>`, el server valida el uuid contra la DB y devuelve la ficha desde el registro, nunca del texto del LLM.

**Rate limits.** Launch de superskills: 10/min por workspace, key `superskills-launch:<workspaceId>` (`index.ts:111,144-150`). Intents clásicos: 10/min global, key `intents:global` (`index.ts:133-136`). Promote y list no tienen rate limit propio (sin LLM involucrado). Todo `/api/workspaces/*` va tras el bearer compartido `SUBSTRATE_API_TOKEN`.

## 5. Gaps de inicialización (verificados)

**(a) PARCIAL — El auto-deploy no re-sincroniza ni verifica el registro de funciones tras el restart.** `auto-deploy-api.sh:49-57` gatea solo con `curl /health` 200 y actualiza el marker; jamás hace `PUT /api/inngest` ni consulta al executor. Mitigado por el polling de 5s del executor (`docker-compose.yml:59-60,69-70`), pero en el caso infeliz (executor caído, signing key rotada) el deploy se declara exitoso con el motor async muerto y el primer aviso llega hasta 10 minutos después por el canary.

**(b) CONFIRMADO — `/health` valida DB y tracing pero NO Inngest ni el schema.** `apps/api/src/routes/health.ts:9-39`: checks = `substrate_db` (`SELECT 1`) + `tracing`. Ningún fetch a `INNGEST_BASE_URL` (127.0.0.1:8288), ningún check de que la tabla `superskills` exista. Agravante: las migraciones están repartidas entre `db/substrate/migrations/` (0005/0007/0008) y `apps/api/db/substrate/migrations/` (0021+, 0035); un bootstrap parcial es invisible para `/health`.

**(c) CONFIRMADO — Sin validación al boot de env vars críticas.** `apps/api/src/env.ts`: `INNGEST_EVENT_KEY`, `INNGEST_SIGNING_KEY` (l.8-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 de `index.ts:64-65` cubren solo serve-host y tracing. Sin `INNGEST_EVENT_KEY` el fallo aparece recién en el primer `inngest.send`; sin `NOTIFY_EMAIL_OPS` los `trace_failed` se descartan en silencio.

**(d) PARCIAL — Launch con Inngest caído devuelve 502 (correcto) pero deja huérfanos; con función sin registrar devuelve 201 sin ejecución.** El `inngest.send` es el último paso de `launch-plan.ts:133-141` y su throw propaga a 502 (`routes/superskills.ts:207-213`), pero para entonces ya existen un intent `'running'` y un trace `'queued'` sin rollback, que el reaper no caza (solo caza `step_executions` running). Y si el executor está vivo pero la función no está registrada (ventana post-deploy), el send acepta el evento → 201 `{launched:true}` y silencio total hasta el canary.

**(e) CONFIRMADO — Sin orden de arranque garantizado.** `agent-squad-api.service:3-4` ordena contra el daemon Docker (`After=docker.service`), no contra los contenedores `substrate-postgres` (5433) ni `substrate-inngest` (8288), que son compose-managed. La conexión Postgres es lazy y el boot no la prueba (`index.ts:396-407` banner sin tocar DB). En un reboot del box Hetzner el API puede quedar listening antes que sus dependencias.

## 6. Plan de inicialización pre-workflow

Checklist ordenado para garantizar superskills activos ANTES de cualquier workflow:

1. **[Boot] Validar env completo en prod**: agregar `assertProdEnvComplete()` junto a los guards existentes de `index.ts:64-65`, exigiendo `INNGEST_EVENT_KEY` + `INNGEST_SIGNING_KEY`, `SUBSTRATE_API_TOKEN`, `NOTIFY_EMAIL_OPS` (o WARN ruidoso) y al menos un path LLM viable (binario `claude` con sesión, o `LLM_API_FALLBACK=true` + `ANTHROPIC_API_KEY`). Cierra el gap (c).
2. **[Boot] Verificar schema**: `SELECT to_regclass(...)` para `superskills`, `plan_drafts`, `intents`, `plans`, `traces`, `step_executions` al arrancar; fatal si falta alguna. Cubre el riesgo de los dos directorios de migraciones (gap b).
3. **[Boot] Fail-fast de DB**: un `SELECT 1` real antes del banner de listening (hoy la conexión es lazy, gap e).
4. **[systemd] Orden de arranque**: `ExecStartPre` en el drop-in de `agent-squad-api.service` con espera activa de `pg_isready -h 127.0.0.1 -p 5433` y TCP a 127.0.0.1:8288, acotado por `TimeoutStartSec` (subir desde 90s si hace falta). Cierra el gap (e).
5. **[Deploy] Re-sync Inngest post-restart**: en `auto-deploy-api.sh`, tras el health 200, forzar `curl -X PUT http://127.0.0.1:4000/api/inngest` para no depender solo del polling de 5s. Cierra la primera mitad del gap (a).
6. **[Deploy] Verificar registro de funciones**: consultar la API del executor (endpoint tipo `http://127.0.0.1:8288/v1/apps`, forma exacta por confirmar contra la versión del executor) y comprobar que la app reporta las 10 funciones de `FUNCTIONS`; si no coincide, NO actualizar el marker y loguear ERROR. Cierra la segunda mitad del gap (a).
7. **[API] Readiness compuesto**: nuevo `GET /ready` separado de `/health`: DB + schema (`to_regclass`) + fetch a `INNGEST_BASE_URL` + conteo de funciones registradas vs `FUNCTIONS.length` + env crítico. Cierra el gap (b) sin abaratar el `/health` existente.
8. **[Deploy] Gatear el marker con `/ready`**, no con `/health`.
9. **[Deploy] Warm-up/smoke**: disparar el canary existente (`canary.ts`) inmediatamente post-deploy en lugar de esperar el cron de 10 min, y exigir su round-trip antes de declarar el deploy OK.
10. **[Launch] Compensación de huérfanos**: en `launch-plan.ts`, envolver el `inngest.send` en try/catch con `updateIntentStatus(intent.id,'failed')` + trace failed (o invertir el orden: send primero, estados `running` después). Cierra la primera mitad del gap (d).
11. **[Launch] Gate de registro en el endpoint**: antes de aceptar un launch, verificar (con cache corto) que `substrate-execute-plan` figura registrada en el executor; si no, 503 `engine_not_ready` en vez de 201 fantasma. Cierra la segunda mitad del gap (d). Ver snippet (c).
12. **[Continuo] Mantener el canary cada 10 min** como red post-hoc (ya existe: `slo-alert.ts:119-128`), ahora como segunda línea y no como única detección.

## 7. Snippets

Pseudo-código listo para adaptar (rutas y nombres reales del repo; el endpoint exacto de la API del executor Inngest queda por confirmar contra la versión desplegada).

### (a) Readiness endpoint compuesto (DB + schema + Inngest + env) en Hono

```ts
// 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 según versión 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: any) => 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 crítico (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

```ini
# /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
```

```bash
# substrate-infra/scripts/auto-deploy-api.sh — reemplaza el bloque de las líneas 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 falló" >&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 — marker NO actualizado" >&2; exit 1; }

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

# 5. Solo ahora, actualizar el marker
git -C "$REPO" rev-parse HEAD > "$MARKER"
```

### (c) Guard en el endpoint de launch: verificar registro de la función antes de aceptar

```ts
// apps/api/src/substrate/engine-ready.ts — guard con cache corto para no penalizar cada launch
let cache: { ok: boolean; at: number } = { 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 según versión 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: string[] = apps?.data?.flatMap((a: any) => a.functions?.map((f: any) => 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 compensación (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 huérfanos 'running'
}
```