# Especificación del módulo Apuestas y OKR (AP) — OKRFEDEF

Versión 1.0 · 7 de septiembre de 2026 (lunes) · Contrato para los equipos que construyen el módulo en paralelo.
Uso previsto: cierre del Retiro 1 de FEDEF en modalidad mixta: tarea asíncrona en casa (martes 8 y miércoles 9 de septiembre de 2026, 20 minutos por persona, con la misma tarjeta del retiro) y sesión virtual de 120 minutos el jueves 10 de septiembre para elegir las apuestas y escribir los OKR v1.0.

Este documento y `docs/CONTRATO_RUTAS_AP.md` son la única fuente de verdad del módulo AP. Donde difieran, manda este documento en reglas de negocio y el contrato en nombres, rutas, cuerpos y props. El módulo Pensamiento estratégico (`docs/ESPECIFICACION_PE.md`, `docs/CONTRATO_RUTAS_PE.md`) es el modelo a calcar en todo lo que aquí no se diga: fases con botón grande y diálogo del kit, participantes por QR/código sin contraseña, sondeo cada 5 s con `usePolling`, curaduría con casillas / Fusionar (2) / ojo tachado, Monitor sin nombres, Resultados con exportar PDF y JSON, ensayo HTTP, pruebas, kit de UI (`resources/js/Components/UI`), tokens de marca, respuestas JSON `{ok, ...}`, todo en español.

---

## 0. Reglas duras (repetidas aquí porque nadie las puede olvidar)

1. No se modifican las tablas `dofa2_*`, `pe_*`, `users` ni ninguna migración existente. Solo migraciones NUEVAS y aditivas con prefijo `2026_09_07_`. Nada de `migrate:fresh` ni seeders destructivos. Enlazar `pe_*` o `dofa2_*` desde una tabla nueva mediante clave foránea NO modifica esas tablas y está permitido.
2. No se instalan paquetes composer ni npm. Se usa lo que ya hay: dompdf, bacon-qr-code, Inertia, Vue 3, Tailwind, axios, Ziggy.
3. Toda la interfaz en español y sin anglicismos. Tablas, rutas, componentes y textos en español; clases PHP en inglés con prefijo `Ap` (`ApSession`, `ApBet`, …).
4. Nunca nombres ni cargos de personas entrevistadas en textos, semillas ni skills. Nunca datos personales de asociados. Los nombres de los participantes solo los ve el facilitador (panel, estado sin `?monitor=1`, editor de OKR para escoger dueño).
5. La llave de Anthropic no se pega en ningún archivo ni salida.
6. No se toca `retiro1/*.html` ni los deck.
7. `npm run build` solo lo ejecuta el integrador o el empaquetador.
8. El texto final de cada agente es un dato para el orquestador: devuelve exactamente lo que se le pide.
9. El paquete de despliegue DEBE incluir `resources/data/**` (el sábado faltó y rompió "Nueva sesión"). Este módulo añade `resources/data/fedef/ap_textos.json` (sección 2.4) y el empaquetador debe llevarlo.
10. Producción (cPanel) no tiene cola ni Docker: toda llamada a la IA es síncrona (≤ 120 s) y ningún proceso depende de `queue:work`.

---

## 1. Objetivo

Terminar lo que faltó del retiro (apuestas estratégicas y OKR v1.0) sin volver a reunir a la junta presencialmente, con dos tareas cortas en casa y una sesión virtual controlada desde el panel del facilitador:

| Fase | Cuándo | Qué hace cada participante | Qué hace el facilitador | Salida |
|---|---|---|---|---|
| 1. Configuración | lunes 7 | — | Crea la sesión enlazada a la PE cerrada y a la DOFA; carga las apuestas semilla (las candidatas nombradas en la sala) a mano o con "Redactar semillas con IA" | Semillas visibles |
| 2. Proponer (tarea en casa) | martes 8 y miércoles 9 | Lee "Lo que decidimos", ve las semillas y las apuestas de los demás (anónimas) y propone hasta 2 apuestas en el formato de cuatro partes; envía (o envía sin proponer) | Cura mientras llegan: edita, oculta, fusiona (2), enlaza capacidad; copia y envía el mensaje de tarea; extiende la vigencia de los códigos si hace falta | Apuestas enviadas y curadas |
| 3. Evaluar (tarea en casa) | miércoles 9 | Califica TODAS las apuestas visibles: impacto en el asociado 1-5, viabilidad 1-5, comentario opcional; envía | Sigue curando (sin agregar apuestas nuevas); ve el ranking en vivo | Evaluaciones |
| 4. Seleccionar (sesión virtual) | jueves 10 | Ve el ranking y la marca "elegida" en el celular (solo lectura) | Comparte el Monitor; marca 4 a 6 apuestas como elegidas; puede editar, fusionar u ocultar | Apuestas elegidas |
| 5. OKR (sesión virtual) | jueves 10 | Ve cada OKR, deja un comentario por apuesta y marca "lo asumo como dueño" en un resultado clave | Redacta en `Ap/Okr` un objetivo y 3 resultados clave por apuesta elegida, a mano o con "Proponer OKR con IA" | OKR v1.0 con dueños |
| 6. Cerrada | jueves 10 | Ve el resumen "Estrategia v1.0: apuestas y OKR" | Resultados, PDF "Estrategia v1.0" y JSON | Estrategia v1.0 |

Después del módulo viene el refinamiento (7 a 26 de septiembre), el Retiro 2 (3 y 4 de octubre) y la operación; ninguno depende de este módulo.

---

## 2. Modelo de datos (tablas nuevas, prefijo `ap_`)

Una sola migración: `database/migrations/2026_09_07_100000_create_ap_tables.php`, protegida con `Schema::hasTable` por tabla, compatible con MySQL 8 y SQLite (pruebas), nombres de índices explícitos (≤ 64 caracteres). Todas las tablas con `id` y `timestamps`. Orden de creación: `ap_sesiones`, `ap_participantes`, `ap_apuestas`, `ap_evaluaciones`, `ap_okr`, `ap_resultados_clave`, `ap_comentarios_okr`, `ap_postulaciones_dueno`, `ap_propuestas_ia`, `ap_resultados`.

Claves foráneas: `cascadeOnDelete` hacia `ap_sesiones`, hacia `ap_apuestas`, hacia `ap_okr` y hacia `ap_resultados_clave`; `nullOnDelete` hacia `users`, hacia `pe_sesiones`, `pe_capacidades` y `dofa2_sesiones` (referenciar una tabla existente desde una nueva no la modifica) y en la autorreferencia `fusionada_en_id`.

### 2.1 Tablas

| Modelo | Tabla | Columnas (además de `id`, `timestamps`) | Índices |
|---|---|---|---|
| `ApSession` | `ap_sesiones` | `project_id` FK projects cascade · `pe_session_id` FK `pe_sesiones` nullable nullOnDelete · `dofa2_session_id` FK `dofa2_sesiones` nullable nullOnDelete · `nombre` string(150) · `fase` enum(ver 3) default `configuracion` · `config` json nullable · `abierta_en` timestamp nullable · `cerrada_en` timestamp nullable · `created_by` FK users nullable nullOnDelete | `ap_sesiones_proyecto_fase` (project_id, fase) |
| `ApParticipant` | `ap_participantes` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `rol_en_sesion` enum('participante','facilitador') default participante · `propuestas_enviadas_en` timestamp nullable · `evaluacion_enviada_en` timestamp nullable · `ultimo_visto_en` timestamp nullable | única `ap_participantes_sesion_user` (sesion_id, user_id) |
| `ApBet` | `ap_apuestas` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete (null = semilla del facilitador o generada por IA) · `titulo` string(80) · `accion` string(300) ("si hacemos…") · `capacidad` string(200) ("con la capacidad…") · `capacidad_pe_id` FK `pe_capacidades` nullable nullOnDelete · `resultado` string(300) ("entonces… para el asociado") · `senal` string(200) ("lo sabremos por…") · `renuncia_implica` string(200) nullable · `cuadrante` enum('FO','FA','DO','DA') nullable · `fuente` enum('semilla','participante','ia') default participante · `visible` bool default true · `fusionada_en_id` FK `ap_apuestas` nullable nullOnDelete · `elegida` bool default false · `orden` unsignedInteger default 0 · `estado` enum('borrador','enviada') default borrador | `ap_apuestas_sesion_vis_orden` (sesion_id, visible, orden); `ap_apuestas_sesion_user` (sesion_id, user_id) |
| `ApEvaluation` | `ap_evaluaciones` | `sesion_id` FK cascade · `apuesta_id` FK `ap_apuestas` cascade · `user_id` FK users nullable nullOnDelete · `impacto` unsignedTinyInteger nullable · `viabilidad` unsignedTinyInteger nullable · `comentario` string(200) nullable | única `ap_evaluaciones_apuesta_user` (apuesta_id, user_id); `ap_evaluaciones_sesion_user` (sesion_id, user_id) |
| `ApOkr` | `ap_okr` | `sesion_id` FK cascade · `apuesta_id` FK `ap_apuestas` cascade · `objetivo` string(200) default '' | única `ap_okr_apuesta` (apuesta_id) |
| `ApKeyResult` | `ap_resultados_clave` | `sesion_id` FK cascade · `okr_id` FK `ap_okr` cascade · `texto` string(200) · `metrica` string(120) nullable · `linea_base` string(60) nullable (texto: admite "por confirmar") · `meta` string(60) nullable (texto) · `fecha` date nullable · `dueno_user_id` FK users nullable nullOnDelete · `dueno_texto` string(80) nullable · `orden` unsignedInteger default 0 | `ap_kr_okr_orden` (okr_id, orden) |
| `ApOkrComment` | `ap_comentarios_okr` | `sesion_id` FK cascade · `apuesta_id` FK `ap_apuestas` cascade · `user_id` FK users nullable nullOnDelete · `comentario` string(200) | única `ap_comentarios_okr_apuesta_user` (apuesta_id, user_id) |
| `ApOwnerNomination` | `ap_postulaciones_dueno` | `sesion_id` FK cascade · `resultado_clave_id` FK `ap_resultados_clave` cascade · `user_id` FK users nullable nullOnDelete | única `ap_postulaciones_kr_user` (resultado_clave_id, user_id); `ap_postulaciones_sesion_user` (sesion_id, user_id) |
| `ApAiProposal` | `ap_propuestas_ia` | `sesion_id` FK cascade · `tipo` enum('semillas','okr') · `apuesta_id` FK `ap_apuestas` nullable nullOnDelete (solo `okr`) · `skill_version` string(40) · `modelo` string(80) · `prompt` longtext · `respuesta_cruda` longtext · `propuesta` json · `estado` enum('propuesta','aplicada','descartada') default propuesta · `tokens_entrada` unsignedInteger default 0 · `tokens_salida` unsignedInteger default 0 · `created_by` FK users nullable nullOnDelete | `ap_propuestas_sesion_tipo` (sesion_id, tipo) |
| `ApResult` | `ap_resultados` | `sesion_id` FK cascade · `version` unsignedInteger · `calculado_en` timestamp · `datos` json (sección 6) · `participantes_incluidos` json | única `ap_resultados_sesion_version` (sesion_id, version) |

Notas:
- Igual que en PE, `ApSession::participantes()` y el scope `ApParticipant::deSesion()` filtran `user_id IS NOT NULL`; `sincronizarParticipantes` borra las huérfanas y nunca retira a un inscrito que ya tenga registros (apuesta, evaluación, comentario, postulación o marca de envío): los devuelve en `conservados`. El celular trata un 403 como orden de recargar.
- Una sesión se puede borrar (`ap.sesiones.destroy`) solo en `configuracion` y sin participantes inscritos; en cualquier otro estado, 409.
- No se crean columnas nuevas en `users`. Permisos reutilizados: `participar_dofa` para participar y `facilitar_dofa` para facilitar. No hay migración de permisos.
- `ap_apuestas.user_id = null` con `fuente = 'semilla'` es una apuesta cargada por el facilitador a mano; `fuente = 'ia'` es una aplicada desde una propuesta de la skill; `fuente = 'participante'` lleva `user_id`. Un participante borrado deja su apuesta con `user_id = null` y `fuente = 'participante'`: sigue contando como apuesta anónima.
- **Apuesta evaluable** (`ApBet::scopeEvaluables`): `visible = true`, `fusionada_en_id IS NULL` y (`fuente != 'participante'` o `estado = 'enviada'`). Solo las evaluables se muestran a los demás participantes, se evalúan, entran en el ranking y pueden marcarse `elegida`.
- `ap_evaluaciones` admite filas parciales (autoguardado): una fila con `impacto` y `viabilidad` no nulos es una **evaluación completa**; una fila solo con `comentario` cuenta como comentario, no como nota.
- `ap_okr` tiene una fila por apuesta (única `apuesta_id`); `ap_resultados_clave` de 0 a 5 filas por OKR (`kr_por_apuesta` = 3 sugeridos; máximo 5 por validación).

### 2.2 `config` json de `ap_sesiones` (`ApSession::CONFIG_POR_DEFECTO`)

```json
{
  "max_apuestas_por_persona": 2,
  "escala": 5,
  "peso_impacto": 0.6,
  "peso_viabilidad": 0.4,
  "fecha_limite_tarea": null,
  "duracion_estimada_min": 20,
  "kr_por_apuesta": 3,
  "min_elegidas": 4,
  "max_elegidas": 6,
  "horizonte_okr": "2027-12-31",
  "permitir_editar_tras_enviar": false,
  "historial_fases": []
}
```

- `escala`: las notas van de 1 a `escala` (siempre 1..5 en este retiro; el backend valida `between:1,escala`).
- `peso_impacto + peso_viabilidad` debe ser 1 (422 al actualizar si no).
- `fecha_limite_tarea`: fecha y hora ISO 8601 con zona (`2026-09-09T20:00:00-05:00`) o null. Es **informativa**: se muestra en el celular, en el mensaje de tarea y en el aviso de vigencia; el backend NO bloquea escrituras después de esa hora. Quien cierra la tarea es el facilitador al avanzar de fase.
- `horizonte_okr`: fecha límite sugerida para los resultados clave (la reciben las skills y el validador la usa solo como aviso, no como error).
- El formulario del panel expone `fecha_limite_tarea`, `min_elegidas`, `max_elegidas`, `kr_por_apuesta`, `horizonte_okr` y `permitir_editar_tras_enviar`; el resto son los del guion. `historial_fases[]` guarda `{de, a, en, por, envios_marcados}` como en PE. `configPublica()` quita `historial_fases`.

### 2.3 Enlaces al crear una sesión (`ApSesionService::crear`)

En la misma transacción: `pe_session_id` = `PeSession::deProyecto($project->id)->cerradas()->orderByDesc('cerrada_en')->first()?->id` (la PE cerrada más reciente; si no hay ninguna cerrada, la `PeSession::activaPara`, y si tampoco, null); `dofa2_session_id` = `pe.dofa2_session_id ?? Dofa2Session::activaPara($project->id)?->id`. Ambos se pueden cambiar con `ap.sesiones.update`. **No se copia nada** de `pe_*` ni `dofa2_*`: el contexto de decisión (sección 5) se lee en cada petición.

No hay apuestas semilla automáticas: las candidatas de la sala las carga el facilitador (a mano o con IA). Sí se siembran, desde `resources/data/fedef/ap_textos.json`, los textos fijos que usan las pantallas y el mensaje de tarea (2.4).

### 2.4 `resources/data/fedef/ap_textos.json`

```json
{
  "formato_apuesta": {
    "accion": "Si hacemos…",
    "capacidad": "con la capacidad…",
    "resultado": "entonces… (para el asociado)",
    "senal": "y lo sabremos por…"
  },
  "ayudas": {
    "titulo": "Un nombre corto para reconocerla en el ranking (máximo 80 caracteres).",
    "accion": "Una decisión concreta, no un deseo. Máximo 300 caracteres.",
    "capacidad": "Una de las capacidades que votamos, o una nueva que habría que construir.",
    "resultado": "Qué cambia para el asociado y cuándo. Máximo 300 caracteres.",
    "senal": "La cifra o el hecho que nos dirá si funcionó.",
    "renuncia_implica": "Opcional: a qué renunciamos si hacemos esto."
  },
  "escala": {
    "impacto": {"1": "Casi nada", "2": "Poco", "3": "Algo", "4": "Mucho", "5": "Decisivo"},
    "viabilidad": {"1": "Muy difícil", "2": "Difícil", "3": "Posible", "4": "Fácil", "5": "Ya casi está"}
  },
  "mensaje_tarea": "…plantilla de la sección 8…"
}
```

El servicio lee este archivo con `File::json` (mismo mecanismo que `pe_semillas.json`) y una prueba de esquema comprueba que existe y que tiene las cuatro claves.

### 2.5 Constantes y métodos de los modelos (nombres obligatorios)

`ApSession`
- `FASE_CONFIGURACION`, `FASE_PROPONER`, `FASE_EVALUAR`, `FASE_SELECCIONAR`, `FASE_OKR`, `FASE_CERRADA`; `FASES` (lista ordenada); `ETIQUETAS_FASE` (sección 3); `ETIQUETA_AVANCE`; `CONFIG_POR_DEFECTO`; `CUADRANTES = ['FO','FA','DO','DA']`; `ETIQUETAS_CUADRANTE` (FO "Fortaleza-Oportunidad", FA "Fortaleza-Amenaza", DO "Debilidad-Oportunidad", DA "Debilidad-Amenaza").
- Relaciones: `project()`, `sesionPe()` (belongsTo `PeSession`, `pe_session_id`), `sesionDofa()` (belongsTo `Dofa2Session`), `creador()`, `participantes()`, `usuarios()`, `apuestas()`, `evaluaciones()`, `okrs()`, `resultadosClave()`, `comentariosOkr()`, `postulaciones()`, `propuestasIa()`, `resultados()`.
- Scopes: `deProyecto`, `noCerradas`, `cerradas`, `enFase`.
- Estáticos: `activaPara(int $projectId): ?ApSession` (misma regla que PE: la de mayor id no cerrada; si no, la última cerrada; si no, null), `activaDelProyectoActivo()`, `indiceFase`, `esFaseValida`.
- Fases: `puedeAvanzarA`, `siguienteFase`, `faseAnterior`, `faseAlcanzada`, `estaEnFase`, `esCerrada`, `etiquetaFase`, `esFaseDeParticipacion()` (proponer, evaluar, okr), `esFaseAsincrona()` (proponer, evaluar).
- Config: `configCompleta`, `configValor`, `configPublica`, `permiteEditarTrasEnviar`, `maxApuestasPorPersona(): int`, `escala(): int`, `pesos(): array{impacto: float, viabilidad: float}`, `fechaLimiteTarea(): ?Carbon`, `minElegidas()`, `maxElegidas()`, `krPorApuesta()`.
- Otros: `participanteDe(User|int)`, `ultimoResultado(): ?ApResult`, `ultimoResultadoPe(): ?PeResult`, `ultimoResultadoDofa(): ?Dofa2Result`, `versionDatos(): ?string` (sección 4.7).

`ApParticipant`: `ROL_PARTICIPANTE`, `ROL_FACILITADOR`, `ROLES`; `DINAMICAS = ['propuestas','evaluacion']`; `COLUMNA_ENVIO = ['propuestas' => 'propuestas_enviadas_en', 'evaluacion' => 'evaluacion_enviada_en']`; `FASE_DE_DINAMICA = ['propuestas' => 'proponer', 'evaluacion' => 'evaluar']`; `DINAMICA_DE_FASE` (proponer ⇒ propuestas, evaluar ⇒ evaluacion, resto null); scopes `deSesion`, `conRol`, `queParticipan`, `queEnviaron($dinamica)`, `huerfanos`; `envio(string $dinamica): bool`, `esFacilitador()`.

`ApBet`: `MAX_TITULO = 80`, `MAX_ACCION = 300`, `MAX_CAPACIDAD = 200`, `MAX_RESULTADO = 300`, `MAX_SENAL = 200`, `MAX_RENUNCIA = 200`; `FUENTE_SEMILLA`, `FUENTE_PARTICIPANTE`, `FUENTE_IA`, `FUENTES`; `ESTADO_BORRADOR`, `ESTADO_ENVIADA`; `PARTES = ['accion','capacidad','resultado','senal']`; scopes `deSesion`, `visibles`, `evaluables`, `deUsuario`, `semillas` (user_id null), `elegidas`, `ordenados` (orden, id); relaciones `autor()`, `capacidadPe()` (belongsTo `PeCapability`), `evaluaciones()`, `okr()` (hasOne), `comentariosOkr()`, `fusionadaEn()`, `absorbidas()`; `estaCompleta(): bool` (titulo y las cuatro partes no vacíos), `esEvaluable(): bool`, `partesFaltantes(): array`.
`ApEvaluation`: `MAX_COMENTARIO = 200`; scopes `deSesion`, `deUsuario`, `deUsuarios`, `deApuesta`, `completas` (impacto y viabilidad no nulos), `conComentario`; `estaCompleta()`, `paraCalculadora()`.
`ApOkr`: `MAX_OBJETIVO = 200`; relaciones `apuesta()`, `resultadosClave()` (ordenados); `estaCompleto(): bool` (objetivo no vacío y ≥ 1 KR y todos los KR completos).
`ApKeyResult`: `MAX_TEXTO = 200`, `MAX_METRICA = 120`, `MAX_LINEA_BASE = 60`, `MAX_META = 60`, `MAX_DUENO = 80`, `MAX_POR_OKR = 5`, `LINEA_BASE_POR_CONFIRMAR = 'por confirmar'`; relaciones `okr()`, `dueno()`, `postulaciones()`; `estaCompleto(): bool` (texto, meta, fecha y dueño: `dueno_user_id` o `dueno_texto`), `camposFaltantes(): array`, `duenoEtiqueta(): ?string` (sección 4.5).
`ApOkrComment`: `MAX_COMENTARIO = 200`; scopes `deSesion`, `deUsuario`, `deApuesta`.
`ApOwnerNomination`: scopes `deSesion`, `deUsuario`, `deResultadoClave`.
`ApAiProposal`: `TIPO_SEMILLAS`, `TIPO_OKR`; `ESTADO_PROPUESTA`, `ESTADO_APLICADA`, `ESTADO_DESCARTADA`; `apuestasPropuestas()`, `okrPropuesto()`, `notasParaElFacilitador()`.
`ApResult`: scopes `deSesion`, `ultimoPrimero`; estático `siguienteVersion($sesionId)`; `datosPublicos()` (quita `por_participante`).
`User` (añadir, sin migración): `apSesiones()` belongsToMany. `Project` (añadir): `apSessions()`.

---

## 3. Fases y máquina de estados

```
configuracion → proponer → evaluar → seleccionar → okr → cerrada
```

`ETIQUETAS_FASE`: configuracion "Configuración" · proponer "Tarea: proponer apuestas" · evaluar "Tarea: evaluar apuestas" · seleccionar "Selección (sesión virtual)" · okr "OKR (sesión virtual)" · cerrada "Cerrada".

Rótulos del botón grande de avance en el panel (`ETIQUETA_AVANCE`, clave = fase destino): proponer "Abrir la tarea: proponer" · evaluar "Abrir la tarea: evaluar" · seleccionar "Abrir la selección (sesión virtual)" · okr "Abrir OKR" · cerrada "Cerrar y consolidar". El botón de retroceso se rotula "Volver a {etiqueta de la fase anterior}". Ambos con diálogo de confirmación del kit (`Modal`); "Cerrar y consolidar" en variante peligro con el texto "¿Cerrar la sesión y consolidar la Estrategia v1.0 con lo guardado? Podrá recalcular después.".

### 3.1 Reglas de transición (`ApSesionService::cambiarFase($sesion, $fase, User $por)`)

- Solo `facilitar_dofa`. Solo a la fase siguiente o a la anterior (`puedeAvanzarA`); si no, 409 con el mismo texto que PE. Misma fase: no hace nada y devuelve 200.
- Con `lockForUpdate` dentro de una transacción, como PE.
- **Al avanzar** desde una fase con dinámica (proponer, evaluar) se ejecuta `marcarEnviosCompletos($sesion, $faseQueSeCierra)` (3.3) y se registra `envios_marcados` en el historial.
- Condiciones para avanzar:
  - a `proponer`: sin condición dura. Si no hay ninguna apuesta visible, el panel muestra el aviso "No hay apuestas semilla; los participantes verán solo el contexto y sus propias propuestas" y el diálogo lo repite, pero se permite abrir.
  - a `evaluar`: al menos 2 apuestas evaluables (409 "Se necesitan al menos dos apuestas visibles para abrir la evaluación."). Al avanzar, los borradores completos de participantes pasan a `enviada` (3.3); los incompletos quedan en `borrador` y no son evaluables.
  - a `seleccionar`: sin condición. El aviso "n de N participantes enviaron su evaluación" aparece en el diálogo.
  - a `okr`: al menos 1 apuesta con `elegida = true` (409 "Marque al menos una apuesta como elegida antes de abrir OKR."). Si hay menos de `min_elegidas` o más de `max_elegidas`, aviso en el diálogo, no bloqueo.
  - a `cerrada`: sin condición; se ejecuta `ApResultadosService::calcular($sesion, $por)` y se guarda `cerrada_en = now()`. Si hay OKR incompletos, el diálogo lo advierte ("k resultados clave incompletos; saldrán marcados 'por completar' en la Estrategia v1.0").
- `abierta_en = now()` la primera vez que se pasa a `proponer`.
- **Al retroceder** a la fase X: `deshacerEnvios($sesion, X)` limpia las marcas de la dinámica de X y de las posteriores (tabla 3.2). Retroceder a `proponer` deja las apuestas como están (las `enviada` siguen `enviada`; el participante que quiera editarlas necesita `permitir_editar_tras_enviar` o que el facilitador las edite). Retroceder a `seleccionar` u `okr` conserva `elegida`, OKR, comentarios y postulaciones. Retroceder desde `cerrada`: `cerrada_en = null` (los `ap_resultados` se conservan como versiones anteriores). Retroceder a `configuracion` limpia todas las marcas pero no borra ningún dato.
- Nunca se borran datos de participantes al cambiar de fase.

### 3.2 Dinámicas, columnas de envío y fases

| Dinámica (`{dinamica}` de `ap.enviar`) | Fase en la que se escribe | Columna en `ap_participantes` | "Completa" para el marcado automático (3.3) | Validación del botón Enviar |
|---|---|---|---|---|
| `propuestas` | proponer | `propuestas_enviadas_en` | ≥ 1 apuesta propia completa (`estaCompleta()`) | 0 a `max_apuestas_por_persona` apuestas propias, TODAS completas (faltantes = `[{apuesta_id, partes: [...]}]`); se permite enviar con 0 ("Enviar sin proponer") |
| `evaluacion` | evaluar | `evaluacion_enviada_en` | ≥ 1 evaluación completa | una evaluación completa por CADA apuesta evaluable (faltantes = ids sin las dos notas) |

Retroceder a `proponer` limpia ambas columnas; retroceder a `evaluar` limpia `evaluacion_enviada_en`.

### 3.3 Marcado automático al avanzar (decisión, ver 12)

Como en PE, `marcarEnviosCompletos` marca a **todo participante con rol participante que tenga al menos un registro completo en la dinámica que se cierra** y todavía no esté marcado. Además, al cerrar `proponer`:
- cada apuesta propia en `borrador` que esté completa pasa a `estado = 'enviada'` (queda evaluable);
- cada apuesta propia en `borrador` incompleta se conserva en `borrador`, no es evaluable y no aparece a los demás; el panel la lista con la etiqueta "Borrador incompleto" y el facilitador puede completarla (pasa a `enviada` con `fuente` intacta) u ocultarla.

Consecuencia: en la práctica todo lo guardado con sentido cuenta; la marca sirve para el panel (quién terminó), para bloquear la edición y para el cálculo intermedio.

### 3.4 Qué ve el participante en cada fase (`Ap/Participar`)

En TODAS las fases la pantalla lleva arriba el panel plegable `Contexto` ("Lo que decidimos", sección 5), cerrado por defecto salvo la primera vez que entra en `proponer` (se abre y se guarda `ap_contexto_visto` en `localStorage`).

| Fase | Componente | Texto de espera o comportamiento |
|---|---|---|
| configuracion | `Espera` | "La tarea aún no está abierta" (en la práctica no se llega: `/participar` resuelve PE o DOFA mientras AP esté en configuración) |
| proponer | `Proponer` | Tarea en casa; reentrada libre (4.1). Tras enviar: resumen de lo propio en solo lectura y aviso "Enviado. La evaluación se abrirá cuando el facilitador la habilite; esta pantalla cambiará sola." |
| evaluar | `Evaluar` | Tarea en casa; reentrada libre (4.2). Tras enviar: "Evaluación enviada. Gracias. La sesión virtual del jueves continúa desde aquí." |
| seleccionar | `Seleccion` | Ranking en vivo (sondeo `ap.ranking` cada 5 s) con la marca "Elegida"; solo lectura |
| okr | `Okr` | OKR por apuesta elegida; comentario por apuesta; "Lo asumo como dueño" por resultado clave |
| cerrada | `Cierre` | "Estrategia v1.0: apuestas y OKR" con el resumen público |

---

## 4. Reglas de cada dinámica

Comunes a todas las escrituras del participante (`/ap/sesiones/{sesion}/...`):
- Exige `participar_dofa`, inscripción con `rol_en_sesion = participante` (403), fase correcta (409, `DofaException::faseIncorrecta`), no haber enviado esa dinámica salvo `permitir_editar_tras_enviar` (409 con `motivo: 'ya_enviado'`). Se reutiliza `App\Services\Dofa\DofaException` tal cual.
- Autoguardado en cada toque (retardo de 400 ms para texto; los chips de nota guardan de inmediato); `IndicadorGuardado` como en DOFA/PE. Ante 409 el celular deja de reintentar y recarga.
- Inscripción automática: `ParticiparController::index` inscribe al usuario en la sesión AP resuelta con `firstOrCreate` (mismo patrón que PE), salvo si es facilitador, si la sesión está cerrada o si no tiene `participar_dofa`. `/participar/estado` actualiza `ultimo_visto_en` de la inscripción AP cuando el módulo resuelto es `ap`.
- **Funcionamiento asíncrono en varios días.** `proponer` y `evaluar` duran días. Nada depende del navegador: el estado está en la base (`ap_apuestas`, `ap_evaluaciones`, marcas). Al reentrar, `Ap/Participar` pinta exactamente lo guardado: borradores con su texto, notas ya puestas, progreso n/N, y sitúa la vista en la primera apuesta sin evaluar (ancla `#apuesta-{id}`). No hay tiempo de sesión ni cuenta regresiva: la fecha límite se muestra como texto ("Fecha límite: miércoles 9 de septiembre, 8:00 p. m.") y, pasada, como "La fecha límite ya pasó; aún puede enviar mientras el facilitador no cierre la tarea". La tarjeta impresa sigue valiendo: el facilitador extiende la vigencia sin regenerar códigos (sección 7). Si `acceso.vigente` rechaza a un participante (tarjeta vencida), ve la pantalla estándar de acceso vencido; no se pierde nada de lo guardado.
- El facilitador nunca se inscribe como participante; el panel puede inscribir usuarios con `ap.sesiones.participantes`.

### 4.1 Proponer (`proponer`)

- El celular muestra: `Contexto` (plegable), "Apuestas ya propuestas" (semillas y apuestas evaluables de los demás, anónimas, con las cuatro partes, cuadrante como `Etiqueta` y capacidad enlazada si la hay; las propias marcadas "propuesta por mí") y "Mis apuestas" (hasta `max_apuestas_por_persona`).
- `POST /apuestas` crea una apuesta propia vacía o con los campos que lleguen (`user_id` = participante, `fuente = 'participante'`, `estado = 'borrador'`, `visible = true`, `orden` = siguiente). Máximo `max_apuestas_por_persona` (2) propias no fusionadas: 422 "Ya propuso el máximo de 2 apuestas".
- `PATCH /apuestas/{apuesta}` guarda campos parciales (autoguardado): `titulo`, `accion`, `capacidad`, `capacidad_pe_id`, `resultado`, `senal`, `renuncia_implica`, `cuadrante`; cada uno validado por longitud; cadenas vacías se guardan como vacías. Solo el autor (403), solo en `proponer` (409), no fusionada ni oculta por el facilitador (409 `motivo: 'curada'`), no enviada salvo config (409 `ya_enviado`).
- `DELETE /apuestas/{apuesta}`: igual, borra la fila (sus evaluaciones aún no existen en esta fase).
- Selector "con la capacidad…": lista de las capacidades visibles de la PE enlazada (texto + tipo) para llenar `capacidad_pe_id` y copiar el texto en `capacidad`; también se puede escribir texto libre (queda `capacidad_pe_id = null`). Si el participante edita el texto después de elegir una capacidad, `capacidad_pe_id` se conserva mientras el texto empiece por el texto de la capacidad; si no, el celular lo pone en null.
- Enviar (`ap.enviar` con `propuestas`): valida que todas las propias estén completas (3.2). Al enviar, cada apuesta propia pasa a `estado = 'enviada'`. Con 0 apuestas el diálogo dice "Enviar sin proponer: pasará directo a evaluar cuando se abra la evaluación".
- Curaduría del facilitador (panel, en `configuracion..seleccionar`; ver 4.6): las apuestas de participantes son visibles para el facilitador desde que se guardan (también los borradores, rotulados), como en PE.
- Tablero (monitor y panel): contador `n_enviaron / n_inscritos`, número de apuestas evaluables y tarjetas anónimas de las apuestas evaluables en orden de llegada.

### 4.2 Evaluar (`evaluar`)

- El celular muestra una tarjeta por apuesta evaluable (título, cuatro partes plegables, capacidad enlazada, cuadrante) con dos filas de chips 1..`escala` ("Impacto en el asociado" y "Viabilidad", con los rótulos de `ap_textos.escala` al tocar) y un `CampoArea` opcional "Comentario (≤ 200)". Barra "n de N" arriba, fija. Las propias también se evalúan (decisión 12.5) y llevan la marca discreta "propuesta por mí".
- `POST /evaluaciones` `{evaluaciones: [{apuesta_id, impacto?, viabilidad?, comentario?}]}` upsert parcial por (apuesta_id, user_id). 422 si la apuesta no es evaluable de esa sesión o la nota está fuera de 1..`escala`. `null` en una nota la borra.
- Enviar: 409 con `faltantes: [ids sin las dos notas]`.
- Curaduría durante `evaluar`: editar texto, ocultar, fusionar, enlazar capacidad, cambiar cuadrante. **No** se agregan apuestas nuevas (409 en `agregar`): quien ya envió tendría una evaluación incompleta. Fusionar mueve las evaluaciones al destino; si el evaluador ya tenía evaluación en el destino, prevalece la del destino y se descarta la de origen (los comentarios de la absorbida se conservan como comentarios de la destino cuando el destino no tenía comentario de ese evaluador). Ocultar no borra evaluaciones; se ignoran mientras esté oculta. Un participante que ya envió y cuyo total N cambia por una fusión u ocultamiento sigue marcado como enviado (su N baja, no sube).
- Tablero: ranking en vivo (4.4) con `en_vivo: true`, `n_evaluadores`, y por apuesta los comentarios anónimos.

### 4.3 Seleccionar (`seleccionar`, sesión virtual)

- Monitor (`Ap/Monitor`): ranking con barras (puntaje, impacto, viabilidad), `n` evaluaciones y comentarios anónimos plegables por apuesta; resaltadas las `elegida`; rótulo "Empate" en las empatadas; línea de corte en `max_elegidas`.
- Facilitador (panel): marca `elegida` con `POST /curar/apuestas {editar: [{id, elegida: true}]}`; el panel muestra "k elegidas de 4 a 6" y avisa (no bloquea) por fuera del rango. También editar texto, fusionar (conserva evaluaciones) y ocultar. Una apuesta oculta o fusionada no puede quedar `elegida` (el servicio la desmarca al ocultar o fusionar; 422 si se intenta marcar).
- Celulares (`Seleccion`): ranking de solo lectura (título, puntaje, impacto, viabilidad, n, rango, "Elegida"), sondeo `ap.ranking` cada 5 s. Sin comentarios (van en el monitor compartido) y sin nombres.
- No hay dinámica de participante; no hay marca.

### 4.4 Puntaje, ranking y empates (`ApResultadosService`, clase pura `ApCalculadora` para lo aritmético)

Entrada de `ApCalculadora::ranking(array $apuestas, array $evaluaciones, array $config): array`: `apuestas` = `[{id, orden}]` (solo evaluables), `evaluaciones` = `[{user_id, apuesta_id, impacto|null, viabilidad|null, comentario|null}]`, `config` = `{peso_impacto, peso_viabilidad, escala, max_elegidas}`.

- Solo cuentan las **evaluaciones completas** (impacto y viabilidad no nulos). En el cálculo consolidado (`calcular`) solo las de participantes con la marca `evaluacion_enviada_en` (tras avanzar, todos los que tocaron algo, por 3.3); en el tablero en vivo, todas.
- Por apuesta: `n` = número de evaluaciones completas; `prom_impacto` = media de impacto; `prom_viabilidad` = media de viabilidad; `puntaje = peso_impacto × prom_impacto + peso_viabilidad × prom_viabilidad`. Con `n = 0`: promedios y puntaje = 0 y `sin_evaluar = true`. Precisión completa en el cálculo; promedios y puntaje redondeados a 2 decimales al presentar; el orden y los empates usan el puntaje redondeado a 2 decimales.
- `desacuerdo` = desviación estándar poblacional del impacto (2 decimales; 0 si n < 2), para que el facilitador vea dónde no hubo consenso.
- `n_evaluadores` = número de usuarios distintos con ≥ 1 evaluación completa.
- **Orden del ranking**: `puntaje` desc, luego `n` desc, luego `prom_impacto` desc, luego `orden` asc, luego `id` asc.
- **Rango** por competencia: las apuestas con el mismo `puntaje` (2 decimales) comparten rango y el siguiente rango salta (1, 1, 3). `empatada = true` cuando comparte puntaje con otra; `empatadas` = grupos `[[ids...]]` con ≥ 2 miembros.
- `sugeridas` = ids de las primeras `max_elegidas` del orden; `empatadas_en_corte` = ids con el mismo puntaje que la última sugerida cuando hay más apuestas con ese puntaje fuera del corte (para que el facilitador decida en la sesión).
- `comentarios` por apuesta: textos de `ap_evaluaciones.comentario` no vacíos, en orden de `updated_at`, sin autor.
- Nada se pondera por persona: cada evaluación completa vale igual.

### 4.5 OKR (`okr`, sesión virtual)

- Facilitador (`Ap/Okr`): una tarjeta por apuesta elegida (en orden de ranking) con las cuatro partes en solo lectura, campo `objetivo` (≤ 200) y tabla de resultados clave (`kr_por_apuesta` filas por defecto; "Agregar resultado clave" hasta 5) con `texto` (≤ 200), `metrica` (≤ 120), `linea_base` (≤ 60, botón "por confirmar" que escribe el literal), `meta` (≤ 60), `fecha` (selector de fecha), dueño: selector de participantes inscritos (`dueno_user_id`, con nombre y cargo, solo aquí) o texto libre (`dueno_texto`, cargo genérico). Autoguardado por apuesta con retardo de 1500 ms (`PUT /apuestas/{apuesta}/okr` con el lote completo) e `IndicadorGuardado`. Estado por apuesta: "Completo" / "k de m resultados clave incompletos".
- `PUT /apuestas/{apuesta}/okr` `{objetivo, resultados_clave: [{id?, texto, metrica?, linea_base?, meta?, fecha?, dueno_user_id?, dueno_texto?, orden}], eliminar: int[]}` reemplaza el objetivo y sincroniza los KR (crea sin `id`, actualiza con `id`, borra los de `eliminar`; borrar un KR borra sus postulaciones por cascada, el diálogo lo advierte si tiene). Validación: 0 a 5 KR; cada KR con `texto` 1..200 (un KR totalmente vacío se ignora en vez de fallar); `fecha` con formato `Y-m-d`; `dueno_user_id` debe ser un usuario inscrito en la sesión (422); `dueno_user_id` y `dueno_texto` pueden ir ambos (prevalece el usuario al etiquetar). Fechas posteriores a `horizonte_okr` no fallan: vuelven con `avisos: ["El KR 2 vence después del horizonte 2027-12-31"]`. Permitido en `okr` y `cerrada` (para corregir tras el cierre y recalcular); 409 en otras fases. La apuesta debe estar `elegida` (409).
- "Proponer OKR con IA" por apuesta: `POST /apuestas/{apuesta}/okr/ia` ejecuta `ApOkrIA` (sección 6.2) síncrono (≤ 120 s), guarda una fila en `ap_propuestas_ia` (`tipo = 'okr'`, `apuesta_id`) y devuelve la propuesta; el editor la vuelca en el formulario (sin guardar) y el facilitador edita y guarda; al guardar, la propuesta usada pasa a `aplicada`. Si la IA no está configurada, el botón aparece deshabilitado con la ayuda "La llave de la IA no está configurada; redacte a mano".
- **Completitud** (`ApKeyResult::estaCompleto`): `texto`, `meta`, `fecha` y dueño (`dueno_user_id` o `dueno_texto`) no vacíos. `linea_base` y `metrica` no son obligatorias (la línea base puede ser "por confirmar"). `ApOkr::estaCompleto`: objetivo no vacío, ≥ 1 KR y todos completos. El panel y `Ap/Okr` muestran completos/incompletos por apuesta y en total.
- **Etiqueta pública del dueño** (`duenoEtiqueta()`): `dueno_texto` si no está vacío; si no, el `cargo` del usuario si lo tiene; si no, su `name`. Se usa en celulares, monitor, PDF y JSON. Un dueño de KR es un compromiso público de la estrategia, no un voto: por eso aquí sí puede aparecer un nombre (decisión 12.8). Nunca se muestran iniciales de evaluadores ni autores de apuestas.
- Participantes (`Okr.vue`): ven cada apuesta elegida con su OKR (objetivo y KR con métrica, línea base, meta, fecha y dueño etiquetado). Por apuesta: un `CampoArea` "Su comentario (≤ 200)" con autoguardado (`PUT /apuestas/{apuesta}/comentario-okr`, upsert; vacío borra la fila) y, por KR, un botón "Lo asumo como dueño" (`POST /resultados-clave/{kr}/asumir`, alterna; `ap_postulaciones_dueno`). Una postulación es una sugerencia: el facilitador la ve en `Ap/Okr` junto al KR ("n personas se postulan": iniciales solo para él) y decide el dueño. Sin marca de envío: no hay `ap.enviar` en esta fase. El celular recarga cuando cambia `datos_version` (4.7), con `preserveState` para no perder un comentario a medio escribir.
- Tablero: por apuesta elegida, objetivo, KR con completitud y `n_comentarios`, `n_postulaciones` por KR; sin nombres.

### 4.6 Curaduría de apuestas (facilitador, `POST /curar/apuestas`)

Cuerpo `{agregar?: [...], editar?: [...], fusionar?: [{origen_id, destino_id}], eliminar?: int[]}` (formato exacto en el contrato 2.3). Reglas:
- `agregar`: solo en `configuracion` y `proponer` (409 después). Crea con `user_id = null`, `fuente = 'semilla'`, `estado = 'enviada'`, `visible = true`, `orden` siguiente. Debe estar completa (titulo y cuatro partes; 422 con los campos faltantes).
- `editar` (texto de las partes, `capacidad_pe_id`, `cuadrante`, `renuncia_implica`, `visible`, `orden`, `estado`): en `configuracion..okr` (en `cerrada` solo `orden` y `cuadrante`). `elegida`: solo en `seleccionar` y `okr`; marcar una oculta o fusionada, 422. Al poner `visible = false` en una `elegida`, se desmarca `elegida` (sus OKR se conservan pero no se muestran). Editar el texto de una apuesta de participante no cambia su `user_id` ni su `fuente`.
- `fusionar` (2 a la vez, como PE): la de `origen_id` pasa a `visible = false`, `fusionada_en_id = destino_id`, `elegida = false`; sus evaluaciones se mueven al destino (regla de 4.2); sus comentarios OKR y su OKR no se mueven (solo existen para elegidas). No se puede fusionar una ya fusionada, consigo misma, ni una con OKR guardado (422 "Borre primero el OKR de la apuesta que va a absorber"). Permitido en `proponer..seleccionar`.
- `eliminar`: borra la fila y, por cascada, evaluaciones, OKR, comentarios y postulaciones. Solo en `configuracion` y `proponer` (409 en las demás: usar ocultar). Con evaluaciones existentes nunca ocurre (no hay evaluaciones antes de `evaluar`).
- Ocultar (`visible = false`) no borra nada; una apuesta oculta no se evalúa (422), no entra en el ranking y no puede estar elegida.
- Enlazar capacidad: `capacidad_pe_id` debe ser una `pe_capacidades` de la sesión PE enlazada (422 si no); al enlazar, si `capacidad` está vacío se copia el texto de la capacidad.
- Toda escritura de curaduría cambia `updated_at` de las filas tocadas y, por tanto, `datos_version` (los celulares recargan en ≤ 5 s).

### 4.7 Versión de datos para el celular

`ApSession::versionDatos()` = máximo `updated_at` (string) entre `ap_apuestas`, `ap_okr` y `ap_resultados_clave` de la sesión, o null. **No** incluye `ap_evaluaciones` ni `ap_comentarios_okr` ni `ap_postulaciones_dueno` (cambian con cada toque de cada participante y recargarían 16 celulares cada 5 s durante la tarea). Va en `/participar/estado` como `datos_version` cuando `modulo = 'ap'`; `Ap/Participar` recarga cuando cambia. El ranking en `seleccionar` se refresca con `ap.ranking` (sondeo propio del componente), no con recargas.

---

## 5. Contexto de decisión ("Lo que decidimos", `ApContextoService::contexto(ApSession): array`)

Se lee en cada petición (no se copia, no se cachea en la base) y va como prop `contexto` a todas las páginas del módulo y como bloque `contexto` en `ap_resultados.datos`.

### 5.1 Qué se lee y de dónde

| Bloque | Fuente | Regla |
|---|---|---|
| `pe.aspiracion` | `PeResult` último de `pe_session_id` → `datos.aspiracion` | grupo con `id = ganador_id` (titulo, tema, votos, pct); si `ganador_id` es null, `empatadas` con los grupos de `empatados` |
| `pe.donde` | `datos.donde.dimensiones` | por dimensión, la opción `ganadora` (texto, votos, pct) o `empatadas` (textos) con `empate = true` |
| `pe.capacidades` | `datos.capacidades.lista` + `pe_capacidades` en vivo | todas las de `lista` (visibles al cerrar) con `votos`, `pct`, `top`; el `tipo` (tenemos/construimos) se sobreescribe con el valor **actual** de `pe_capacidades.tipo` por `id`, porque el facilitador lo marca después del cierre desde `Pe/Resultados`; se añaden las capacidades visibles creadas después del cierre (sin votos) |
| `pe.renuncias` | `datos.renuncias.lista` | texto, evidencia, si, no, pct_si, aprobada, umbral |
| `pe.notas` | `pe_sesiones.notas` | solo `frase_para_guardar`, `que_dejamos_de_hacer`, `capacidad_unica`, `renuncia_mas_costosa` (las demás no) |
| `dofa` | `Dofa2Result` último de `dofa2_session_id` | `mime.celda`, `mime.zona`, `cruzada.cuadrante_dominante`, `cruzada.cuadrantes_empatados`, `cruzada.cuadrantes.{FO,FA,DO,DA}.pct`, `top_cruces` = 3 primeros de `cruzada.top_valor` con los textos buscados en `datos.factores` por código (misma construcción que `dofa.top_cruces` en PE 6.2) |

`pe.disponible = false` (y el resto en null) si no hay sesión PE enlazada o no tiene `PeResult`; igual `dofa.disponible`. El panel lo avisa (`avisos.pe_sin_resultado`, `avisos.dofa_sin_resultado`). Nunca nombres ni iniciales.

### 5.2 Estructura

```json
{
  "generado_en": "2026-09-07T18:00:00-05:00",
  "pe": {
    "disponible": true,
    "sesion": {"id": 1, "nombre": "Retiro 1 · Pensamiento estratégico", "cerrada_en": "2026-09-06T12:10:00-05:00", "version_resultado": 1},
    "aspiracion": {"titulo": "FEDEF gana cuando…", "tema": "asociado", "votos": 9, "pct": 64.3, "empate": false, "empatadas": []},
    "donde": {
      "segmento": {"texto": "Base actual (profundizar)", "votos": 8, "pct": 53.3, "empate": false, "empatadas": []},
      "producto": {"texto": "Crédito (más asociados con crédito)", "votos": 10, "pct": 66.7, "empate": false, "empatadas": []},
      "territorio": {"texto": null, "votos": 0, "pct": 0, "empate": true, "empatadas": ["Sabana de Occidente (4 municipios)", "Multiempresa sin territorio"]}
    },
    "capacidades": [{"id": 4, "texto": "Agilidad en la decisión de crédito", "tipo": "tenemos", "votos": 11, "pct": 73.3, "top": true, "rango": 1}],
    "renuncias": [{"id": 2, "texto": "Crecer la base social como objetivo en sí", "evidencia": "…", "si": 13, "no": 2, "pct_si": 86.7, "aprobada": true}],
    "umbral_renuncias": 70,
    "notas": {"frase_para_guardar": null, "que_dejamos_de_hacer": null, "capacidad_unica": null, "renuncia_mas_costosa": null}
  },
  "dofa": {
    "disponible": true,
    "sesion": {"id": 32, "nombre": "Retiro 1 · DOFA", "version_resultado": 3},
    "mime": {"celda": "V", "zona": "RESISTA", "zona_descripcion": "Retener y mantener"},
    "cuadrante_dominante": "FO", "cuadrantes_empatados": [],
    "cuadrantes": {"FO": 29.0, "FA": 24.1, "DO": 25.3, "DA": 21.6},
    "top_cruces": [{"rango": 1, "interno": "D1", "externo": "A3", "interno_texto": "…", "externo_texto": "…", "cuadrante": "DA", "valor": 4.316}]
  }
}
```

### 5.3 Presentación (`Components/Ap/Contexto.vue`)

Panel plegable (`Plegable` de PE reutilizable) con cinco bloques en este orden: Aspiración ganadora · Dónde ganar (tres líneas; "Empate: …" cuando aplique) · Capacidades (lista con `Etiqueta` tenemos/construimos y votos; las `top` primero) · Renuncias (solo aprobadas, con % Sí; enlace "ver todas" que despliega el resto) · Lo que dijo el DOFA (cuadrante dominante, zona MIME y las 3 parejas). Con `disponible = false` en un bloque: "Sin datos de {módulo}". Mismo componente en las páginas del facilitador (Panel, Okr, Resultados) con el mismo prop.

---

## 6. IA: skills `ap_semillas.md` y `ap_okr.md`

Cliente: `App\Services\ClaudeService::askJson($prompt, $system, $jsonSchema, maxTokens 8000, timeout 120)`. Carga con `App\Services\Dofa\DofaSkill::cargar('ap_semillas')` / `cargar('ap_okr')` (el cargador es genérico; no se modifica). Registro en `ai_messages` con `module = 'ap'`. Si `ClaudeService::configurada() === false`, los endpoints devuelven 400 `{ok:false, error:'La llave de la IA no está configurada; …'}` sin excepción no controlada. Toda respuesta se valida contra el esquema; si falla, `DofaException::negocio` y la fila queda `descartada`. Se guardan `prompt`, `respuesta_cruda`, tokens y `skill_version`.

Reglas comunes a las dos skills (van en el texto de cada una): español neutro de Colombia; **cada parte de una apuesta y cada campo de un KR con ≤ 14 palabras**; nunca nombres ni cargos de personas concretas (ni entrevistadas ni de la junta); nunca datos personales de asociados; sin siglas distintas de FEDEF, FNA, OKR, KR, BSC, SAS; sin adjetivos vacíos; cifras solo si vienen del contexto; salida un único objeto JSON sin texto adicional.

### 6.1 `resources/ai/skills/ap_semillas.md` (`ApSemillasIA::proponer(ApSession $sesion, ?string $instrucciones, User $por): ApAiProposal`)

Encabezado obligatorio (para `DofaSkill::version`):

```
# Skill: redacción de apuestas semilla

Versión: 1.0
Fecha: 7 de septiembre de 2026
Uso: Retiro 1 de FEDEF, cierre asíncrono, fase de configuración de apuestas
```

1. **Rol.** Consultor senior en planeación estratégica de entidades solidarias colombianas (fondos de empleados), facilitador del retiro de FEDEF. Tarea única: redactar apuestas estratégicas candidatas, coherentes con lo que la junta ya decidió, para que el facilitador las revise y las cargue como semillas. No decide, no vota, no inventa datos.
2. **Entrada** (prompt de usuario, JSON): el contexto de decisión de la sección 5.2 tal cual; `candidatas_actuales` = apuestas ya cargadas (`[{titulo, accion, capacidad, resultado, senal}]`, para no repetirlas ni contradecirlas; puede ir vacío); `capacidades_disponibles` = `[{id, texto, tipo}]` de la PE (para `capacidad_pe_id`); `instrucciones` opcionales del facilitador (priman sobre el estilo, nunca sobre las prohibiciones). Sin nombres de personas.
3. **Salida.** Un único objeto JSON con este esquema exacto (es también el `$jsonSchema` de `askJson`):

```json
{
  "apuestas": [
    {
      "titulo": "Crédito ágil para la base actual",
      "accion": "aprobamos el crédito de consumo en 24 horas para asociados con historial",
      "capacidad": "Agilidad en la decisión de crédito",
      "capacidad_pe_id": 4,
      "resultado": "más de la mitad de los asociados activos tiene crédito vigente en 2027",
      "senal": "el porcentaje de asociados con crédito pasa de 49,3 % a 55 %",
      "renuncia_implica": "dejar la línea hipotecaria",
      "cuadrante": "FO",
      "motivo": "Une la capacidad más votada con el dónde ganar en producto (crédito) y segmento (base actual)."
    }
  ],
  "notas_para_el_facilitador": [
    "La renuncia 6 (línea hipotecaria) quedó aprobada; dos apuestas la usan como renuncia implicada."
  ]
}
```

   Esquema JSON (draft 2020-12): objeto con `apuestas` (array, minItems 4, maxItems 6, items: objeto con `titulo` string, `accion` string, `capacidad` string, `capacidad_pe_id` integer|null, `resultado` string, `senal` string, `renuncia_implica` string|null, `cuadrante` enum ["FO","FA","DO","DA",null], `motivo` string; `required` los nueve) y `notas_para_el_facilitador` (array de string); `required: ["apuestas","notas_para_el_facilitador"]`; `additionalProperties: false` en todos los niveles.
4. **Reglas de redacción.** Exactamente 6 apuestas (si `candidatas_actuales` ya trae k, proponer 6 igualmente pero distintas de ellas). Cada apuesta en cuatro partes que se leen seguidas: "Si hacemos {accion}, con la capacidad {capacidad}, entonces {resultado}; lo sabremos por {senal}". `accion` empieza en primera persona del plural en presente ("aprobamos", "abrimos", "dejamos"); `resultado` dice qué cambia para el asociado y cuándo (año 2027 a 2029); `senal` es una cifra o un hecho observable; `titulo` ≤ 8 palabras. Cada parte ≤ 14 palabras. `capacidad` debe ser una de `capacidades_disponibles` (con su `capacidad_pe_id`) en al menos 4 de las 6; si propone una capacidad nueva, `capacidad_pe_id = null` y `motivo` lo justifica. Al menos una apuesta por cada "dónde ganar" ganador (segmento, producto, territorio) y ninguna que contradiga una renuncia aprobada; si una apuesta implica una renuncia aprobada, `renuncia_implica` la cita. `cuadrante`: FO si usa una fortaleza para una oportunidad, FA si defiende con fortaleza, DO si corrige debilidad para aprovechar, DA si evita; null si no aplica. Sin metas de tamaño puras ("crecer 30 %") como resultado: el resultado se ve desde el asociado.
5. **Notas.** Tensiones entre apuestas, cifras del contexto que conviene confirmar, dónde no hubo consenso (empates).
6. **Prohibido.** Nombres, cargos, datos de asociados; proponer OKR; más o menos de 6 apuestas; texto fuera del JSON; inventar capacidades con `capacidad_pe_id` de una que no existe; cifras que no están en el contexto.

`ApSemillasIA` valida: 4 a 6 apuestas, títulos y cuatro partes no vacíos, longitudes dentro de los máximos de `ApBet` (recorta a la longitud máxima y anota), `capacidad_pe_id` existente en la PE enlazada o null (si no existe, lo pone en null y anota), `cuadrante` válido o null. "Aplicar" (`ap.semillas.aplicar` con `{propuesta_id, indices?: int[]}`) crea las apuestas elegidas (por defecto todas) con `fuente = 'ia'`, `user_id = null`, `estado = 'enviada'`, `orden` a continuación de las existentes, marca la propuesta `aplicada` y las anteriores del tipo `descartada`. Después el facilitador edita con `ap.curar.apuestas`. Permitido en `configuracion` y `proponer`.

### 6.2 `resources/ai/skills/ap_okr.md` (`ApOkrIA::proponer(ApSession $sesion, ApBet $apuesta, ?string $instrucciones, User $por): ApAiProposal`)

Encabezado obligatorio:

```
# Skill: propuesta de OKR para una apuesta estratégica

Versión: 1.0
Fecha: 7 de septiembre de 2026
Uso: Retiro 1 de FEDEF, sesión virtual de cierre, fase OKR
```

1. **Rol.** El mismo consultor. Tarea única: dada UNA apuesta elegida y el contexto, proponer un objetivo y tres resultados clave medibles para el primer ciclo (hasta `horizonte_okr`). No cambia la apuesta, no propone otras.
2. **Entrada** (JSON): `apuesta` = `{titulo, accion, capacidad, resultado, senal, renuncia_implica, cuadrante, puntaje, prom_impacto, prom_viabilidad, comentarios: [string]}` (comentarios anónimos de la evaluación); `contexto` = sección 5.2; `horizonte_okr` (fecha); `hoy` (fecha); `cargos_disponibles` = lista de cargos genéricos válidos para `dueno_sugerido` (por defecto: "Gerencia", "Junta directiva", "Comité de crédito", "Comité de control social", "Coordinación de bienestar", "Coordinación comercial", "Coordinación de tecnología", "Contabilidad y riesgos"); `instrucciones` opcionales. Sin nombres.
3. **Salida.** Un único objeto JSON con este esquema exacto:

```json
{
  "objetivo": "Convertir la agilidad del crédito en la razón para quedarse en FEDEF",
  "resultados_clave": [
    {
      "texto": "Aprobar el 90 % de los créditos de consumo en 24 horas",
      "metrica": "% de solicitudes aprobadas en ≤ 24 h",
      "linea_base": "por confirmar",
      "meta": "90 %",
      "fecha": "2027-06-30",
      "dueno_sugerido": "Comité de crédito",
      "motivo": "Es la señal directa de la apuesta y hoy no hay cifra registrada."
    }
  ],
  "notas_para_el_facilitador": [
    "El KR 3 depende de la centralización de datos, que el DOFA marca al 50 %."
  ]
}
```

   Esquema JSON: objeto con `objetivo` string, `resultados_clave` (array, minItems 2, maxItems 4, items: objeto con `texto` string, `metrica` string, `linea_base` string, `meta` string, `fecha` string con `pattern ^\d{4}-\d{2}-\d{2}$`, `dueno_sugerido` string, `motivo` string; `required` los siete), `notas_para_el_facilitador` (array de string); `required` los tres; `additionalProperties: false`.
4. **Reglas.** Exactamente 3 resultados clave (el esquema admite 2 a 4 por tolerancia). El objetivo es cualitativo, inspirador y ≤ 14 palabras, sin cifra. Cada KR tiene una cifra en `meta` (número, porcentaje o cantidad) y una `fecha` entre `hoy` y `horizonte_okr`, escalonadas (al menos una antes de seis meses). `linea_base`: la cifra del contexto si existe (por ejemplo 49,3 % de asociados con crédito, 584 retiros, 0 desembolsos hipotecarios); si no hay dato, el literal exacto "por confirmar". `dueno_sugerido` es siempre un cargo genérico de `cargos_disponibles` (nunca un nombre). Al menos un KR mide la `senal` de la apuesta; al menos un KR mide algo que nota el asociado. Cada campo de texto ≤ 14 palabras. Nada de KR de actividad pura ("hacer reuniones"): miden resultado.
5. **Notas.** Dependencias con capacidades por construir, con renuncias y con debilidades del DOFA; datos que hay que levantar antes del refinamiento (7 a 26 de septiembre).
6. **Prohibido.** Nombres o cargos de personas concretas; cambiar la apuesta; más de un objetivo; fechas fuera del rango; texto fuera del JSON.

`ApOkrIA` valida: objetivo no vacío, 2 a 4 KR con texto y meta no vacíos, fecha válida (si está fuera de rango la deja y anota), longitudes (recorta y anota), `dueno_sugerido` no vacío. La propuesta vuelve al editor como `{objetivo, resultados_clave: [{texto, metrica, linea_base, meta, fecha, dueno_texto: dueno_sugerido, dueno_user_id: null}]}` y solo se persiste cuando el facilitador guarda (`ap.okr.guardar` con `propuesta_id` opcional, que la marca `aplicada`). Permitido en `okr` y `cerrada`.

---

## 7. Vigencia de los códigos de acceso (`ap.sesiones.vigencia`)

Las tarjetas impresas del retiro llevan un código con vencimiento (`users.acceso_expira_en`, por defecto 72 horas desde su asignación: la mayoría vence entre el lunes 7 y el martes 8). Las tarjetas **no se regeneran**: solo se extiende la vigencia.

- Aviso en el panel (`avisos.vigencia`): `{fecha_limite: string|null, vencidos: n, vencen_antes_del_limite: n, total_inscritos: N, hasta_mas_lejana: string|null}` calculado sobre los usuarios inscritos con rol participante: `vencidos` = `acceso_expira_en` no nulo y anterior a ahora; `vencen_antes_del_limite` = `acceso_expira_en` no nulo y anterior a `fecha_limite_tarea` (o a ahora + 48 h si no hay fecha límite). Con `vencen_antes_del_limite > 0` el panel muestra en amarillo "n participantes tienen la tarjeta vencida o vencerán antes de la fecha límite" y el botón "Extender vigencia 30 días".
- `POST /sesiones/{sesion}/extender-vigencia` `{dias?: int 1..90 = 30}`: para cada `ap_participantes` con rol participante y `user_id` no nulo cuyo usuario tenga `es_participante_retiro = true`, fija `acceso_expira_en = now()->addDays($dias)` **solo si** el valor actual es anterior a esa fecha (nunca acorta; un valor nulo, que significa acceso sin vencimiento, no se toca). **No toca** `codigo_acceso`, `token_acceso` ni `es_participante_retiro`, no llama a `AccesoService::regenerar` ni a `asignarAcceso` (`ParticipantesController@regenerar` sigue existiendo para el caso de una tarjeta perdida, pero el módulo AP no lo usa). Responde `{ok, actualizados: n, sin_cambio: m, hasta: iso}`. Solo `facilitar_dofa`; permitido en cualquier fase. No se anota en `config.historial_fases`; se escribe una línea en el log de la aplicación con el id de sesión, `dias` y `actualizados` (sin nombres ni códigos).
- Los usuarios con `acceso_expira_en = null` (acceso sin vencimiento) no se tocan y cuentan como vigentes.
- Los usuarios que NO están inscritos en la sesión AP no se tocan aunque tengan tarjeta. Para incluirlos, el facilitador los inscribe primero con "Inscribir o retirar" (que ya lista los usuarios con `participar_dofa`).

---

## 8. Mensaje de tarea (`ApSesionService::mensajeTarea(ApSession): string`)

Se arma en el backend (probable por prueba) desde la plantilla `mensaje_tarea` de `ap_textos.json` y viaja como prop `mensaje_tarea` del panel; el botón "Copiar mensaje de tarea" lo pone en el portapapeles (`navigator.clipboard.writeText`, con respaldo de un `textarea` seleccionable si el navegador lo niega) y muestra "Copiado". Texto exacto (las llaves se reemplazan; sin nombres):

```
Apreciados integrantes de la junta y la gerencia:

Gracias por el trabajo del retiro. Antes de la sesión virtual del {fecha_sesion_virtual} queda una tarea corta: proponer y evaluar las apuestas estratégicas.

Cómo hacerlo ({duracion} minutos, desde el celular o el computador):
1. Entre a {url_acceso} con el código de su tarjeta del retiro (la misma del sábado).
2. Lea "Lo que decidimos": la aspiración, dónde ganar, las capacidades y las renuncias que votamos.
3. Proponga hasta {max_apuestas} apuestas con el formato "si hacemos… con la capacidad… entonces… lo sabremos por…". Si prefiere no proponer, use "Enviar sin proponer".
4. Cuando se abra la evaluación, califique todas las apuestas: impacto en el asociado y viabilidad, de 1 a 5, con un comentario si quiere.

Fecha límite: {fecha_limite}.
Su avance se guarda solo: puede entrar y salir las veces que necesite.
```

- `{url_acceso}` = `config('app.url').'/acceso'` (en producción `https://estrategia.evolucionamos.com/acceso`; en local `http://localhost:8080/acceso`).
- `{fecha_limite}` = `fecha_limite_tarea` en español largo ("miércoles 9 de septiembre, 8:00 p. m."); si es null, "la que indique el facilitador".
- `{fecha_sesion_virtual}` = `config.fecha_sesion_virtual` si existe, si no "jueves 10 de septiembre" (constante `ApSession::FECHA_SESION_VIRTUAL_POR_DEFECTO`; se puede editar en el panel).
- `{duracion}` = `duracion_estimada_min`; `{max_apuestas}` = `max_apuestas_por_persona`.
- El mensaje no lleva nombres, códigos ni enlaces con token.

---

## 9. Resolución de `/participar` y sondeo (cambios en `Acceso/ParticiparController`)

Regla de resolución (`ParticiparController::resolverModulo(Project $project): array{modulo, sesion}`), que amplía la de PE sin cambiarla:

1. `$ap = ApSession::activaPara($project->id)`.
2. Si `$ap` existe, no está cerrada y su fase no es `configuracion` → `['ap', $ap]`.
3. Se aplica la regla PE actual (ESPECIFICACION_PE 5, pasos 1 a 4) y se obtiene `[$modulo, $sesion]` ∈ {pe, dofa}.
4. Si `$ap` existe y está cerrada: si `$sesion` es null → `['ap', $ap]`; si no, manda la sesión que se abrió más tarde (`inicio = abierta_en ?? created_at`): si `inicio(ap) >= inicio(sesion)` → `['ap', $ap]`; si no → `[$modulo, $sesion]`.
5. En cualquier otro caso (sin AP, o AP en configuración) → `[$modulo, $sesion]`.

Prioridad efectiva: AP abierta > PE abierta > DOFA; entre cerradas, la más reciente. Ejemplos: AP 1 en `configuracion` el lunes → los celulares siguen viendo el cierre de PE 1; AP 1 en `proponer` el martes → AP; AP 1 cerrada el jueves → AP (abierta después que PE 1 y DOFA 32).

`GET /participar` renderiza `Ap/Participar` cuando el módulo es `ap`. `GET /participar/estado` devuelve `modulo: 'ap'`, `fase`, `sesion_id`, `factores_version: null`, `datos_version` (4.7) y `actualizado_en`. Como los nombres de fase de AP (`proponer`, `evaluar`, `seleccionar`, `okr`) son distintos de los de PE y DOFA, el cambio de módulo dispara la recarga en las páginas viejas sin tocarlas; `Ap/Participar.vue` compara además `modulo`.

Middleware `RedirigirParticipanteAlRetiro`: añadir `'ap/*'` a `PERMITIDAS`.

Menú del facilitador (`AppLayout.vue`): nueva entrada `{clave: 'ap', texto: 'Apuestas', href: route('ap.panel', dofa.project_id), activo: estaEn('ap.*')}` justo después de "Pensamiento", con las mismas condiciones y el mismo prop compartido `$page.props.dofa`.

---

## 10. Resultados (`ap_resultados.datos`) y exportaciones

### 10.1 Cálculo

`ApResultadosService::calcular(ApSession $sesion, ?User $por): ApResult` en cualquier fase desde `evaluar` (409 antes); versión incremental; `participantes_incluidos = {propuestas: [iniciales], evaluacion: [iniciales]}`. Al cerrar se calcula automáticamente; "Recalcular" desde `Ap/Resultados` crea otra versión (por ejemplo tras completar un KR en `cerrada`).

### 10.2 Estructura de `datos` (= cuerpo de `estrategia_v1_ap_{sesion}_v{version}.json`)

```json
{
  "sesion": {"id": 1, "nombre": "Retiro 1 · Apuestas y OKR", "fase": "cerrada", "calculado_en": "…", "version": 1, "abierta_en": "…", "cerrada_en": "…", "n_inscritos": 16, "fecha_limite_tarea": "…"},
  "contexto": { "…sección 5.2…" },
  "config": {"peso_impacto": 0.6, "peso_viabilidad": 0.4, "escala": 5, "max_elegidas": 6, "min_elegidas": 4},
  "apuestas": {
    "n_evaluables": 12, "n_semillas": 5, "n_de_participantes": 7, "n_evaluadores": 14, "n_enviaron_propuestas": 13, "n_enviaron_evaluacion": 14,
    "ranking": [
      {"rango": 1, "id": 7, "titulo": "…", "accion": "…", "capacidad": "…", "capacidad_pe_id": 4, "capacidad_pe_texto": "…", "capacidad_pe_tipo": "tenemos", "resultado": "…", "senal": "…", "renuncia_implica": null, "cuadrante": "FO", "fuente": "semilla",
       "n": 14, "prom_impacto": 4.36, "prom_viabilidad": 3.71, "puntaje": 4.1, "desacuerdo": 0.61, "empatada": false, "elegida": true, "sugerida": true, "sin_evaluar": false,
       "comentarios": ["…", "…"]}
    ],
    "empatadas": [[3, 9]], "sugeridas": [7, 2, 3, 9, 11, 5], "empatadas_en_corte": [],
    "ocultas": [{"id": 8, "titulo": "…", "fusionada_en_id": 7}]
  },
  "elegidas": [
    {"apuesta_id": 7, "rango": 1, "titulo": "…", "accion": "…", "capacidad": "…", "resultado": "…", "senal": "…", "renuncia_implica": null, "cuadrante": "FO", "puntaje": 4.1,
     "okr": {"objetivo": "…", "completo": true,
       "resultados_clave": [{"id": 1, "orden": 1, "texto": "…", "metrica": "…", "linea_base": "por confirmar", "meta": "90 %", "fecha": "2027-06-30", "dueno": "Comité de crédito", "completo": true, "faltantes": [], "n_postulaciones": 2}]},
     "comentarios_okr": ["…"]}
  ],
  "okr_resumen": {"n_elegidas": 5, "n_okr_completos": 4, "n_kr": 15, "n_kr_completos": 13, "n_kr_por_confirmar": 6},
  "por_participante": {"resumen": [{"user_id": 3, "iniciales": "NF", "propuestas": 2, "propuestas_enviadas": true, "evaluaciones": 12, "evaluacion_enviada": true, "comentarios_okr": 3, "postulaciones": 1}]},
  "participantes_incluidos": {"propuestas": ["NF", "JP"], "evaluacion": ["…"]},
  "version_motor": "1.0.0"
}
```

- `apuestas.ranking` incluye las evaluables al momento del cálculo; `ocultas` lista las no evaluables con `fusionada_en_id`.
- `elegidas[].okr.resultados_clave[].dueno` = `duenoEtiqueta()` (4.5); `n_postulaciones` es un número, sin identidades.
- `por_participante` solo lo ve el facilitador; `datosPublicos()` lo quita. `resumen_publico` para `Cierre` (lo arma `ApResultadosService::resumenPublico`): aspiración del contexto, las elegidas en orden de rango con las cuatro partes y su OKR (objetivo + KR con meta, fecha y dueño), y `okr_resumen`.

### 10.3 PDF "Estrategia v1.0" (`resources/views/pdf/ap_estrategia_v1.blade.php`, dompdf, tamaño carta)

Orden: portada breve ("Estrategia v1.0: apuestas y OKR", proyecto, sesión, fecha y hora de cálculo, versión) · 1 Lo que decidimos (contexto: aspiración, dónde ganar, capacidades con tipo y votos, renuncias aprobadas con % Sí, cuadrante dominante y las 3 parejas del DOFA) · 2 Ranking de apuestas (tabla # / Apuesta (título) / Impacto / Viabilidad / Puntaje / n / Elegida ☑; "Empate" cuando aplique; sin comentarios) · 3 Las apuestas elegidas (una caja por apuesta con las cuatro partes en párrafo seguido, capacidad enlazada, cuadrante, renuncia implicada) · 4 OKR v1.0 (por apuesta: objetivo y tabla KR / Métrica / Línea base / Meta / Fecha / Dueño; los incompletos con "por completar" en cursiva) · 5 Pendientes para el refinamiento (lista automática: líneas base "por confirmar", KR incompletos, empates en el corte). Sin nombres ni iniciales de evaluadores ni autores; los dueños con su etiqueta pública. Nombre del archivo: `estrategia_v1_ap_{sesion}_v{version}.pdf`.

### 10.4 JSON (`ap.export.json`)

Descarga `estrategia_v1_ap_{sesion}_v{version}.json` con `datosPublicos()` (sin `por_participante`), `Content-Disposition: attachment`.

---

## 11. Reparto de archivos por equipo

| Equipo | Archivos que crea o edita (y ningún otro) |
|---|---|
| **Contrato** (este agente) | `docs/ESPECIFICACION_AP.md`, `docs/CONTRATO_RUTAS_AP.md` |
| **Base AP** | `database/migrations/2026_09_07_100000_create_ap_tables.php`, `app/Models/Ap*.php`, `resources/data/fedef/ap_textos.json`, `app/Services/Ap/ApCalculadora.php` (pura), `app/Policies/ApSessionPolicy.php` (+ registro donde esté `PeSessionPolicy`), relaciones nuevas en `User` y `Project`, `routes/ap.php` registrado en `bootstrap/app.php` (`then:`, bajo `['web','auth','acceso.vigente']`) y vacío, `tests/Feature/Ap/ApEsquemaTest.php`, `tests/Feature/Ap/ApCalculadoraTest.php` |
| **Backend AP** | `routes/ap.php` (rutas), `app/Http/Controllers/Ap/*` (`ApController` base, `PanelController`, `CuraduriaController`, `OkrController`, `ParticipacionController`, `ResultadosController`), `app/Services/Ap/*` (salvo `ApCalculadora`), `resources/ai/skills/ap_semillas.md`, `resources/ai/skills/ap_okr.md`, `resources/views/pdf/ap_estrategia_v1.blade.php`, `tests/Feature/Ap/ApFlujoTest.php`, `tests/Feature/Ap/Concerns/CreaEscenarioAp.php` |
| **Frontend AP** | `resources/js/Pages/Ap/*.vue`, `resources/js/Components/Ap/*` (incluido `utilidades.js`; importa `participante.css`, `facilitador.css`, `Plegable`, `PieEnvio`, `TarjetaElegible` de `Components/Pe` cuando sirvan), `resources/js/Layouts/AppLayout.vue` (solo la entrada "Apuestas") |
| **Acceso e integración** | `app/Http/Controllers/Acceso/ParticiparController.php` (resolución de módulo y props de `Ap/Participar`), `app/Http/Middleware/RedirigirParticipanteAlRetiro.php` (`'ap/*'`), `docker/ensayo_ap.php`, `npm run build` final, `php artisan migrate`, pruebas de humo, paquete de despliegue con `resources/data/**` |

Nadie edita `routes/web.php`, `routes/dofa.php`, `routes/pe.php`, `routes/acceso.php`, ni archivos de los módulos DOFA y PE fuera de los listados.

---

## 12. Decisiones tomadas por el agente de contrato

1. **Estado `borrador`/`enviada` en la apuesta, no solo la marca del participante.** Así "las apuestas de los demás" que ve el celular en `proponer` son solo las enviadas (nadie ve un borrador ajeno a medias) y al cerrar `proponer` los borradores completos pasan a `enviada` (3.3) para no perder trabajo de quien no pulsó Enviar en casa.
2. **Marcado automático "al menos un registro completo"**, como PE, porque una evaluación de 10 de 12 apuestas hecha en casa es información válida; el puntaje por apuesta se calcula sobre quienes la evaluaron, así que las evaluaciones parciales no distorsionan.
3. **Fecha límite informativa, no bloqueante.** La tarea la cierra el facilitador al avanzar; una hora de corte automática dejaría por fuera a quien entra tarde el miércoles por la noche.
4. **`datos_version` sin evaluaciones ni comentarios** (4.7) y sondeo propio de ranking (`ap.ranking`) en `seleccionar`, para que 16 celulares no recarguen cada 5 s mientras alguien evalúa.
5. **Cada participante evalúa también sus propias apuestas** (anónimas para los demás, marcadas "propuesta por mí" para él). Excluirlas haría que N cambie por persona y complicaría el progreso y el envío; el sesgo posible se compensa con 14 evaluadores y se ve en `desacuerdo`.
6. **Rango por competencia** (1, 1, 3) y empates por puntaje redondeado a 2 decimales; `sugeridas` y `empatadas_en_corte` con `max_elegidas` para que el facilitador decida en la sesión, no la máquina.
7. **No se agregan apuestas nuevas en `evaluar`** (solo editar, ocultar, fusionar, enlazar) para no invalidar evaluaciones ya enviadas.
8. **Los dueños de KR sí pueden aparecer con nombre** en la Estrategia v1.0 cuando el facilitador escoge un usuario y este no tiene `cargo`: un dueño es un compromiso público, no un voto. La skill solo propone cargos genéricos y el texto libre `dueno_texto` permite dejarlo como cargo. Evaluadores, autores de apuestas, comentaristas y postulantes siguen anónimos en todo lo público.
9. **Postulación "lo asumo como dueño" en tabla propia** (`ap_postulaciones_dueno`), separada del comentario, para que el facilitador vea cuántos se postulan por KR y decida; nunca se asigna sola.
10. **Contexto de decisión leído del `PeResult` con el `tipo` de capacidad sobreescrito en vivo** desde `pe_capacidades`, porque tenemos/construimos se marca después del cierre de la PE.
11. **`ap_propuestas_ia`** con `tipo` (semillas u okr) para la trazabilidad de las dos skills en una sola tabla, calcando `pe_agrupaciones_ia`.
12. **Extensión de vigencia sin regenerar**: nunca se llama a `asignarAcceso`/`regenerar`; solo se mueve `acceso_expira_en` hacia adelante y solo a inscritos. Las tarjetas impresas siguen valiendo.
13. **El mensaje de tarea lo arma el backend** (probable) y el panel solo lo copia.
14. **Se reutilizan** `DofaException`, `DofaSkill`, `usePolling`, `ParticipanteLayout`, `AppLayout`, el kit UI, `Plegable`/`PieEnvio`/`TarjetaElegible` de PE y los permisos `participar_dofa` / `facilitar_dofa`. No hay permisos nuevos, columnas nuevas en `users` ni paquetes nuevos.
15. **PDF en una sola plantilla blade** sin gráficos, como PE.

---

## 13. Criterios de aceptación (verificables)

Comandos: `docker compose -f docker/compose.yaml exec -T app php artisan migrate`, `... php artisan test --filter Ap`, `... php docker/ensayo_ap.php --participantes=3`, `... php docker/smoke_get_routes.php consultor@fycls.com 1 1`.

**Base**
1. `php artisan migrate` crea las 10 tablas `ap_*` sin tocar ninguna otra; `migrate:status` no muestra migraciones anteriores modificadas; `SHOW CREATE TABLE pe_sesiones`, `pe_capacidades`, `dofa2_sesiones` y `users` son idénticos a antes.
2. `ApEsquemaTest`: crea una sesión con `ApSesionService::crear` sobre un escenario con PE cerrada (usa `CreaEscenarioPe`) y comprueba `pe_session_id` y `dofa2_session_id` enlazados; comprueba únicas (segunda evaluación del mismo usuario a la misma apuesta falla; segundo OKR de la misma apuesta falla), cascadas (borrar sesión borra todo; borrar apuesta borra evaluaciones, OKR, KR, comentarios y postulaciones) y `nullOnDelete` en `users`, `pe_capacidades` y `fusionada_en_id`; comprueba que `ap_textos.json` existe con sus cuatro claves.
3. `ApCalculadoraTest`: con evaluaciones de fixture verifica `puntaje` = 0,6·impacto + 0,4·viabilidad a 2 decimales, que las filas sin las dos notas no cuentan, `n` y `n_evaluadores`, orden (puntaje, n, impacto, orden, id), rango por competencia con empate (1, 1, 3), `empatadas`, `sugeridas` con `max_elegidas` = 6 y `empatadas_en_corte`, `desacuerdo` = 0 con n < 2, `sin_evaluar` con n = 0.

**Backend**
4. `php artisan route:list --path=ap` lista exactamente las 28 rutas del contrato con sus nombres (20 del facilitador y 8 del participante).
5. `ApFlujoTest` recorre las 6 fases con 3 participantes y una PE cerrada: semillas (2 a mano), proponer (uno propone 2 y envía, otro 1 y no envía, otro envía sin proponer), avanzar (el borrador completo pasa a `enviada` y su autor queda marcado), evaluar (uno evalúa todo y envía, otro evalúa la mitad y no envía), avanzar (marcado automático del segundo), fusionar dos apuestas conservando evaluaciones, marcar 2 elegidas, 409 al abrir OKR sin elegidas, guardar OKR (1 completo, 1 incompleto), IA con llave sin configurar → 400, comentario y postulación de participante, cerrar; verifica que `ap_resultados.datos` tiene todas las claves de 10.2, `datosPublicos()` sin `por_participante`, 409 al escribir fuera de fase, 409 `ya_enviado`, 422 con 3 apuestas propias, 422 con nota 6, 403 al editar la apuesta de otro, limpieza de marcas al retroceder, `versionDatos()` que cambia al curar y no al evaluar.
6. `ap.sesiones.estado?monitor=1` no contiene `nombre` ni `cargo` en ningún participante ni `autor_iniciales` en ninguna apuesta; sin el parámetro sí.
7. `ap.sesiones.vigencia` con dos inscritos vencidos y uno vigente a 60 días: actualiza 2, deja 1 `sin_cambio`, no cambia `codigo_acceso` ni `token_acceso` de nadie y no toca a un usuario con tarjeta no inscrito.
8. `mensajeTarea()` contiene `/acceso`, "código de su tarjeta", la fecha límite formateada, "20 minutos" y no contiene el `name` de ningún usuario ni ningún `codigo_acceso`.
9. Exportar PDF devuelve `application/pdf` con al menos 2 páginas, con la cadena "Estrategia v1.0" y sin ninguna cadena igual al `name` de un participante que no sea dueño de un KR; `ap.export.json` devuelve `attachment` y JSON válido sin `por_participante`.

**Acceso**
10. Con PE 1 cerrada y AP 1 en `configuracion`, `/participar` sigue devolviendo `Pe/Participar` y `modulo: 'pe'`; con AP 1 en `proponer`, devuelve `Ap/Participar` y `modulo: 'ap'`; con AP 1 cerrada (abierta después de la PE), sigue `ap`. Un GET de `/ap/sesiones/1/apuestas` por un participante no se redirige a `/participar` (PERMITIDAS).
11. La inscripción automática crea la fila `ap_participantes` al primer `/participar` y no la crea para el facilitador ni con la sesión cerrada. Un participante que entra por primera vez en `evaluar` queda inscrito y puede evaluar.

**Frontend**
12. En 360 px todas las pantallas del participante se usan sin desplazamiento horizontal; chips de nota ≥ 44 px; contador de caracteres en cada parte; "Enviar" con diálogo del kit; `IndicadorGuardado` tras cada toque; el panel `Contexto` plegable está en las seis fases.
13. Al reentrar en `proponer` o `evaluar` (cerrar el navegador y volver por código) la pantalla muestra lo guardado y el progreso correcto, sin pasos repetidos.
14. El panel muestra la línea de 6 fases, el botón grande con los rótulos de la sección 3, "Volver a …", "Nueva sesión" con el enlace PE/DOFA preseleccionado, participantes con estado (propuestas n/2 y enviado, evaluación n/N y enviado, comentarios), curaduría de apuestas (agregar con las cuatro partes, editar, fusionar (2), ocultar, elegida, enlazar capacidad), "Redactar semillas con IA", "Copiar mensaje de tarea", el aviso de fecha límite y de vigencia con "Extender vigencia 30 días", accesos Monitor / OKR / Resultados y los avisos de víspera.
15. El monitor no muestra nombres en ninguna fase; en `proponer` muestra `n/16` y tarjetas; en `evaluar` y `seleccionar` barras que cambian en ≤ 5 s tras una evaluación o al marcar elegida; en `okr` los OKR con completitud.
16. `Ap/Okr` guarda con autoguardado, muestra completos/incompletos, "Proponer OKR con IA" (deshabilitado sin llave), las postulaciones por KR y el selector de dueño con participantes inscritos.
17. `Cierre.vue` muestra las elegidas con sus cuatro partes y sus OKR con dueño etiquetado, sin nombres de evaluadores.

**Integración**
18. `docker/ensayo_ap.php --participantes=15` termina en "SIN FALLOS" en menos de 3 minutos contra la aplicación en marcha, cubre las 6 fases (incluidas reentradas en `proponer` y `evaluar`, extensión de vigencia y exportaciones) y borra lo que creó salvo con `--conservar`.
19. `npm run build` compilado una sola vez al final por el integrador; `smoke_get_routes.php` devuelve 200 en `projects/1/ap`; el paquete de despliegue contiene `resources/data/fedef/ap_textos.json` y `resources/ai/skills/ap_*.md`.
