<p align="center">
  <img src="assets/odoo-sdd.svg" alt="dsh-odoo-sdd — Desarrollo Guiado por Especificación para Odoo" width="800" />
</p>

# Spec-Driven Development para Odoo

<div align="center">

<h3>Convertí DeepSeek Harness en un taller Odoo de bucle cerrado:<br/>spec → arquitectura → código → verificación, contra una instancia real</h3>

<!-- Los badges de npm se resuelven contra el paquete publicado. Actualizá
     owner/repo en los badges de GitHub si esto se bifurca a otra cuenta. -->
<p align="center">
  <a href="https://www.npmjs.com/package/dsh-odoo-sdd"><img src="https://img.shields.io/npm/v/dsh-odoo-sdd.svg?style=for-the-badge&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
  <a href="https://github.com/fhidalgodev/dsh-odoo-sdd/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fhidalgodev/dsh-odoo-sdd/ci.yml?style=for-the-badge&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
  <a href="./LICENSE"><img src="https://img.shields.io/github/license/fhidalgodev/dsh-odoo-sdd.svg?style=for-the-badge&color=8b949e&labelColor=161b22" alt="license"/></a>
  <a href="https://www.npmjs.com/package/dsh-odoo-sdd"><img src="https://img.shields.io/npm/dm/dsh-odoo-sdd.svg?style=for-the-badge&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
  <a href="https://github.com/fhidalgodev/dsh-odoo-sdd/stargazers"><img src="https://img.shields.io/github/stars/fhidalgodev/dsh-odoo-sdd.svg?style=for-the-badge&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
  <a href="https://github.com/fhidalgodev/dsh-odoo-sdd/graphs/contributors"><img src="https://img.shields.io/github/contributors/fhidalgodev/dsh-odoo-sdd.svg?style=for-the-badge&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
  <a href="https://github.com/fhidalgodev/dsh-odoo-sdd/discussions"><img src="https://img.shields.io/github/discussions/fhidalgodev/dsh-odoo-sdd.svg?style=for-the-badge&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
</p>

<p align="center">
  <a href="README.md"><b>🇬🇧 English</b></a> &nbsp;•&nbsp;
  <a href="README.es.md"><b>🇪🇸 Español</b></a> &nbsp;•&nbsp;
  <a href="README.zh-CN.md"><b>🇨🇳 简体中文</b></a>
</p>

<p align="center">
  <b>Autor:</b> <a href="https://github.com/fhidalgodev">Franyer Hidalgo</a> — <code>fhidalgo.dev@gmail.com</code>
</p>

<table align="center">
  <tr>
    <td align="center">
      ⭐ <strong>Si el plugin te ahorra tiempo, una estrella ayuda mucho</strong> — es la señal que mantiene vivo el desarrollo.
      <br><br>
      🐛 <strong>¿Encontraste un bug o querés una funcionalidad?</strong> Abrí un issue en el idioma que quieras. Los reportes reproducibles y los "esto no funcionó" honestos son lo más útil que podés mandar.
    </td>
  </tr>
</table>

</div>

---

## ⚡ Resumen

`dsh-odoo-sdd` convierte **DeepSeek Harness** en un pipeline de desarrollo Odoo
guiado por especificación (SDD). Dos ideas lo sostienen:

- **Bucle de feedback cerrado** — el agente instala y actualiza módulos, lee el
  traceback del servidor y reintenta contra una instancia Odoo **real y en
  marcha**. El plugin nunca levanta Docker ni `odoo-bin`: vos le apuntás a una
  instancia que ya tenés (dev/staging) mediante un `.env` gitignored, y las tools
  hablan JSON-RPC estándar.
- **Seguridad del pipeline** — cada fase persiste en disco, las compuertas son
  fail-closed con marcador `APPROVED` explícito, tres fallos consecutivos fuerzan
  un diagnóstico de causa raíz, las iteraciones verify/fix están acotadas,
  `stop.md` detiene todo, y los veredictos son honestos: una verificación fallida
  persiste como FAILED y nunca puede reportarse como éxito.
- **Sin cambio sin spec** — una petición que llega *después* de que el pipeline
  cerró ("ahora cambiá también X" en el mismo chat) es un cambio nuevo, así que
  tiene su propia spec (pequeña) o un waiver explícito de tu parte. El guard cubre
  por igual las mutaciones de instancia y las ediciones de código, y dice cuál de
  las tres salidas está disponible en vez de editar en silencio.

> [!NOTE]
> **Lo que NO es:** un orquestador de infraestructura, un gestor de credenciales
> por chat, ni un auto-committer. No escribe ningún commit y nunca te pide una
> contraseña.

### Requisitos

| Necesitás | Para qué |
|---|---|
| **DSH ≥ 0.2.2-rc.1** sobre **Node ≥ 20** | el plugin se monta como bundle de Cordis y usa el servicio `tools`; ese host declara **Node ≥ 24.9.0**, así que Node 24 es el piso real para ejecutarlo (el código del plugin funciona en 20) |
| Una **instancia Odoo existente** accesible por HTTP(S) | el bucle necesita un servidor real donde instalar y del que leer tracebacks |
| Una **base de datos desechable** (dev/staging) | la verificación instala módulos y escribe datos de prueba |
| *(opcional)* una herramienta de navegador **Playwright** | solo para la capa UI de la verificación y para las capturas de manuales narrados; sin ella esos escenarios quedan marcados para revisión manual |
| *(opcional)* `playwright-core` **1.63.0** (fijada) | viene con el plugin como dependencia opcional; el motor de captura maneja Chromium, Chrome o Edge, y responde `NEEDS_BROWSER` con el remedio en una línea cuando no hay ninguno presente |

---

## 🔭 Cómo funciona

```mermaid
flowchart TD
    A(["Idea o pedido"]) --> C1["1 CLARIFY<br/>entrevista y preguntas de seguridad"]
    C1 --> R2["2 READ_SPEC<br/>spec.md inmutable"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>modelos, vistas, seguridad, plan de pruebas"]
    A3 -->|APPROVED| W4["4 WRITE_CODE<br/>código del módulo y docs OCA"]
    W4 --> V5["5 VERIFY<br/>estático, instalación, RPC, UI"]
    V5 -->|PASSED| D9(["handoff.md - DONE"])
    V5 -->|FAILED| F6["FIX_LOOP<br/>causa raíz, máximo 5 iteraciones"]
    F6 --> V5
    W4 -.-> M7["odoo_module install o upgrade"]
    M7 -.->|traceback| E8["odoo_errors"]
    E8 -.-> F6
```

La especificación es la única fuente de verdad y es **inmutable**: el código se
adapta a la spec, nunca al revés. Las compuertas las responde un humano por
defecto, y se pueden delegar a un agente proxy humano si elegís el modo autónomo.

---

## ✨ Características

- 🎯 **Primero la spec, siempre.** `spec.md` lleva criterios de aceptación
  numerados; `DONE` es inalcanzable sin un veredicto `PASSED` persistido en disco.
- 🧬 **Identidad estable de requisitos.** Cada requisito recibe un id estable
  `REQ-<AREA>-NN` (slug de la sección + ordinal) y un fingerprint comprometido en
  `req-baseline.json`. Reescribir un requisito bajo un id existente es **DRIFT**:
  el avance se rechaza hasta revertirlo o aceptarlo explícitamente
  (`sdd_phase operation=baseline`), así un plan o un test nunca apuntan en
  silencio a un requisito que ya no dice lo que decía.
- 🔗 **Cobertura bidireccional.** Anotá código y tests con `# REQ-<AREA>-NN` y
  `odoo_validate module_dir=… spec_dir=…` verifica las dos direcciones:
  requisitos sin implementación y anotaciones huérfanas cuyo id la spec ya no
  declara. Pisos: `must`/`shall` bloquean en 1.0, `should` se reporta en 0.8,
  `may` es informativo. Cada requisito cae en la escalera
  `covered / untested / test-only / unimplemented`.
- 🔍 **Lint de ambigüedad (advisory).** `mark_spec_loaded` reporta adjetivos sin
  cuantificar, cuantificadores vagos, placeholders `TBD`, pasivas sin actor y
  compuestos and/or, cada uno con una reescritura concreta — consejo, nunca gate.
- 🧪 **Pruebas que pueden fallar de verdad.** `odoo_tests` revisa lo que un log
  de instalación no ve: un archivo de test que Odoo nunca importa (nada lo
  carga), un test que no afirma nada, una excepción tragada, `cr.commit()` que
  rompe el rollback por prueba, `time.sleep`, skips — y mapea cada criterio de
  aceptación a la prueba que lo menciona. `scaffold` escribe stubs RED (lanzan
  hasta implementarse) en vez de passes vacíos.
- 🔴 **RED antes de GREEN en un bug.** En `mode=bug` el pipeline no deja salir de
  `WRITE_CODE` hasta que `test-plan.md` registre la reproducción que falló
  PRIMERO (`red (evidence: …)`, o `pass (red first: …)` ya corregido) — un test
  de regresión que nunca falló no prueba nada.
- 🔁 **Bucle de feedback real.** `odoo_module install` devuelve la salida o el
  traceback del servidor; `odoo_errors` lee `ir.logging`; los fallos se convierten
  en un veredicto FAILED persistido, no en un resumen esperanzado.
- 🧾 **No sólo módulos.** La misma máquina también corre una spec **funcional**
  (`mode=functional`): configurar una instancia viva y cargar datos en lotes
  aprobados por una persona, con el importador propio de Odoo para CSV/Excel, y
  cerrar con un runbook que alguien puede repetir.
  → [El camino funcional](#-el-camino-funcional-configurar-e-importar)
- 🔒 **Las credenciales no son consentimiento.** El primer socket de un proyecto
  necesita una autorización humana explícita atada a `url + db + usuario`
  (`.sdd/grants.json`).
- ⏪ **Rollback que dice la verdad.** Los checkpoints capturan archivos y
  registran cada pre-imagen de `odoo_execute`, y un restore siempre reporta los
  archivos creados después del checkpoint. Lo que no puede deshacer, lo dice.
- 🧱 **Documentación como compuerta.** `odoo_docs` produce los fragmentos OCA
  (`readme/`), el `index.html` de Apps y una entrada de changelog obligatoria — y
  funciona sobre un módulo existente sin spec, sin fase y sin instancia.
- 🧪 **Capas estáticas sin instancia.** `odoo_validate` (estructura + coherencia
  de ACL) y `odoo_security_scan` (SQL concatenado, `sudo()`, `auth="none"`,
  `t-raw` en QWeb…) dan hallazgos con `file:line` antes de instalar nada.
- 📖 **Manuales escritos como una historia, revisados como código.** `odoo_manual`
  lee de la instancia el canon funcional de un modelo (los botones y los estados
  que los muestran, campos requeridos, páginas del notebook, menús, reglas de
  acceso, los sitios exactos de `raise UserError`), planifica un storyboard, le
  hace lint a la prosa contra el canon, captura pantallas anotadas
  **solo-lectura por defecto**, y renderiza un manual que una persona puede
  seguir — una captura etiquetada por instrucción, con la ruta de recuperación
  cuando el canon dice que el botón puede lanzar error.
- 🎬 **Video narrado, la misma evidencia.** `odoo_video` convierte el mismo
  storyboard en un screencast narrado: la frase `procedure` de cada paso se
  vuelve la línea de voz (la habla un comando TTS que **tú** configuras —
  piper-tts offline o edge-tts en la nube) y el subtítulo, `capture` graba la
  sesión del navegador con marcas de tiempo por paso, y `assemble` corta,
  congela el último frame y muxa `video/<flow>.mp4` con ffmpeg. Los subtítulos
  SRT/VTT siempre se entregan (la vía de exportación a cualquier plataforma de
  video); el webm crudo se conserva para re-voicer sin re-capturar.
- 🧑⚖️ **Política que el modelo no puede relajar.** Allowlist, guardas y modo de
  delegación exigen aprobación humana nativa para cambiar.
- 🤖 **Supervisado o autónomo.** El mismo pipeline corre con un humano
  respondiendo cada compuerta, o con un agente proxy humano y rondas de goal sin
  atención hasta `DONE` o `BLOCKED`.
- 📁 **Los specs donde vos quieras.** Al lado de cada proyecto, o todos juntos en
  una carpeta que puedas buscar.
- 🖥️ **Linux, macOS y Windows.** Rutas, escrituras atómicas y permisos del `.env`
  se manejan por plataforma y se prueban en Windows en CI.

---

## 🚀 Inicio rápido

### 1. Instalar en un perfil

```bash
dsh plugin --profile web add dsh-odoo-sdd
```

Eso instala el **paquete publicado en el registro de npm** — sin clonar, sin
compilar, sin nada que construir de tu lado. `dsh plugin` es un forwarder fino de
`pnpm`: corre `pnpm add` dentro del directorio del perfil y después registra el
bundle (`dsh.profile.bundles`). Dos consecuencias que conviene saber:

- **pnpm tiene que estar en tu `PATH`** (`dsh plugin` lo avisa cuando no está).
- Acepta cualquier spec de pnpm, así que podés fijar una versión:
  `dsh plugin --profile web add dsh-odoo-sdd@0.20.0`.

¿Preferís npm pelado — un proyecto que depende del plugin, o un job de CI?

```bash
npm install dsh-odoo-sdd        # 0.20.0
```

> [!IMPORTANT]
> Reiniciá DSH y refrescá la pestaña del navegador después de instalar. Los
> cambios del lado cliente (el panel **Odoo SDD** en Ajustes) se cargan desde el
> paquete instalado.

**¿Estás trabajando en el plugin?** Instalá el checkout. `lib/` es salida de build
y **no** se versiona; `npm install` lo compila con el hook `prepare`, y siempre
podés pedirlo explícito:

```bash
git clone https://github.com/fhidalgodev/dsh-odoo-sdd && cd dsh-odoo-sdd
npm install          # devDependencies: typescript, y prepare compila lib/
npm run host:deps    # peers opcionales, necesarios para compilar (no-save)
npm run build        # genera lib/ — obligatorio, el main es lib/index.js
dsh plugin --profile odoo add .
```

> [!NOTE]
> Una instalación desde **git** corre ese build `prepare` en la máquina del
> consumidor, y pnpm bloquea los scripts de build de las dependencias hasta que
> los permitas: el comando te dice la clave exacta que hay que agregar en
> `allowBuilds` dentro del `pnpm-workspace.yaml` del perfil. Una instalación desde
> el registro no necesita nada de eso — el tarball ya trae `lib/`.

`dsh plugin add` registra el bundle en el `package.json` del perfil
(`dsh.profile.bundles`), y el paquete trae su propio parche de Cordis
(`cordis.patch.yml`) que inserta su fila — así que no hay paso de composición
manual. `dsh --profile <nombre> --dump-config` imprime el árbol compuesto sin
arrancar nada.

### 2. Darle credenciales (una vez por proyecto)

Pedile al agente que corra `odoo_setup mode=check`. Escribe un scaffold **sin
secretos** y la contraseña la completás vos:

```text
odoo_setup mode=interactive url=http://localhost:8069 db=odoo_dev username=admin
# después completá ODOO_PASSWORD (se recomienda una API key de Odoo) en el archivo que imprime
odoo_setup mode=authorize   # te pregunta a VOS, una vez, para autorizar ese destino exacto
```

O a mano — el plugin usa la primera ubicación de esta cascada que exista:

| # | Ubicación | Alcance |
|---|---|---|
| 1 | `ODOO_SDD_ENV_FILE` | override explícito de entorno |
| 2 | `<proyecto>/.sdd/instances/<nombre>.env` | **un destino por fichero**, la instancia activa |
| 3 | `<proyecto>/.sdd/.env` | disposición de un solo destino (también aparece como la instancia `default`) |
| 4 | `~/.config/dsh-odoo-sdd/.env` (respeta `$XDG_CONFIG_HOME`) | scope usuario — un juego de credenciales para todos los proyectos |
| 5 | `<proyecto>/.env` | ubicación legacy, sigue soportada (se reporta como tal) |

**Varios destinos en un mismo proyecto.** Un workspace que necesita un servidor CE
local, uno EE de staging y la instancia de un cliente ya no sobreescribe su único
`.env`: cada destino tiene su fichero bajo `.sdd/instances/`, y uno está activo.

```bash
odoo_setup mode=instance instance=list                       # qué existe, identidad sin secretos
odoo_setup mode=instance instance=add name=staging url=https://stg.example.com db=stg username=ci
# rellena el secreto a mano en .sdd/instances/staging.env, y después:
odoo_setup mode=authorize name=staging                       # autoriza ESE destino
odoo_setup mode=instance instance=use name=staging            # lo activa PARA ESTA SESIÓN
odoo_setup mode=instance instance=remove name=staging         # exige confirm_destructive=true
odoo_setup mode=revoke name=staging                           # retira solo el grant de ese destino
```

**El destino activo pertenece a la SESIÓN, no al proyecto.** Cada chat guarda su
propio puntero (`.sdd/instances/active-<sessionId>.json`), así que dos sesiones
sobre un mismo proyecto pueden trabajar en dos entornos a la vez —una en staging y
otra en el servidor de un cliente— sin que ninguna mueva la de la otra.
**Una sesión que no eligió se PREGUNTA, nunca recibe un valor por defecto.** La
primera operación contra Odoo en un chat nuevo se rechaza y devuelve la pregunta,
con los destinos disponibles y su identidad sin secretos:

```
This session has no environment chosen yet, and this project defines 2 instance(s):
  - acme: https://… db=acme user=admin
  - globex: https://… db=globex user=admin   (the project's last used)
ASK THE DEVELOPER which one THIS session should use, then record the answer:
  - an existing one: `odoo_setup mode=instance instance=use name=<name>`
  - a new one: `odoo_setup mode=instance instance=add name=<name> url=… db=… username=…`, …
```

Heredar el último usado del proyecto es cómodo exactamente una vez y peligroso
después: otra sesión cambiando de entorno redirigiría en silencio a la que está
trabajando, y sus respuestas seguirían diciendo `OK`. Una pregunta por sesión lo
elimina. `.sdd/instances/active.json` se conserva solo como la *sugerencia* que
muestra la pregunta. Un proyecto sin instancias con nombre (solo `.sdd/.env`, la
disposición de un único destino) nunca se pregunta, y
`requireInstanceChoice: false` restaura la herencia anterior para quien la prefiera.

La autorización es **por destino** (`url`+`db`+`user`), así que dos instancias en
el mismo servidor son dos grants. **`authorize` acepta el NOMBRE del destino**, y
eso es lo que hace recuperable el cambio: los grants caducan (12 h), y exigir
activar antes de autorizar era un círculo cerrado — `use` pide grant vivo para el
destino, y `authorize` solo veía el activo. Nombrarlo rompe el círculo: autorizá
`staging` sin dejar `main`.

Activar un destino sin grant vivo se sigue rechazando: el puntero decide a dónde
van las mutaciones, así que no puede apuntar a algo que nadie aprobó. Con **varias**
instancias y **ninguna** activa, toda herramienta de conexión se niega en vez de
elegir por orden, y un `.sdd/.env` antiguo sigue funcionando sin tocarlo, como
`default`, hasta que decidas moverlo tú.

```bash
mkdir -p ~/.config/dsh-odoo-sdd && cd ~/.config/dsh-odoo-sdd
cp <plugin>/.env.example .env && chmod 600 .env
# completá: ODOO_URL, ODOO_DB, ODOO_USERNAME, y UN secreto:
#   ODOO_PASSWORD=<contraseña de la cuenta>   o   ODOO_API_KEY=<api key>
```

**El secreto tiene dos nombres aceptados.** Se leen `ODOO_PASSWORD` y
`ODOO_API_KEY`, y la API key es la recomendada: Odoo la acepta donde acepta una
contraseña por RPC (Ajustes → Usuarios → Claves API, y usá una clave con alcance
global). Si están las dos, gana `ODOO_PASSWORD` y el plugin **reporta** que la otra
fue ignorada, para que una credencial rotada no pase desapercibida.

Hay un solo sitio donde la API key **no** sirve, y es una regla de Odoo, no del
plugin: `odoo_session` y `odoo_import` autentican una **sesión web**, y Odoo
autentica los inicios interactivos con `interactive: True` y solo consulta la tabla
de claves API en el caso contrario. Configurá una contraseña de cuenta si necesitás
esas dos; todo lo demás funciona con la clave.

### Qué API habla el plugin

| Servidor | Transporte | Credencial |
|---|---|---|
| **Odoo 19+** | `POST /json/2/<modelo>/<método>` — parámetros con nombre, `Authorization: Bearer` | API key |
| Cualquier versión | `POST /jsonrpc` — `execute_kw` | contraseña **o** API key |

En 19+ el plugin usa la API moderna JSON-2: sin ida y vuelta de `uid`, parámetros
con nombre validados por el propio Odoo, enrutado a réplica de lectura para los
métodos `@api.readonly`, y errores que llegan como estado HTTP con cuerpo
estructurado. Repliega al JSON-RPC clásico en tres casos: falta la ruta, el servidor es anterior, o **la
credencial no sirve ahí**. Ese último importa: JSON-2 autentica solo con `Authorization: Bearer`,
así que un destino configurado con **contraseña** responde `401` en cada llamada — y un `401`
significa que la petición nunca se ejecutó, así que servirla por el otro transporte no puede
duplicar nada. Una llamada que el servidor SÍ procesó y rechazó (`403`, `422`, un error de
dominio) **nunca** se reenvía. Fijá la
elección con `odooApi: auto | json2 | jsonrpc` en el panel o con `odoo_config`;
`json2` reporta la ruta ausente en vez de degradar en silencio.

Las acciones de negocio (`action_*`, `button_*`, `do_*`) usan siempre `execute_kw`:
JSON-2 no tiene forma posicional, e inventar un mapeo posición→nombre para un método
cuya firma el plugin no conoce es justo el envío equivocado y silencioso que evita.

> [!WARNING]
> Nunca pegues una contraseña en el chat, en un spec, en un commit ni en un
> issue. El plugin rechaza un `.env` legible por grupo/otros, enmascara el
> secreto en toda salida de tool, y guarda las cookies de sesión en
> `.sdd/session.json` (modo 600) sin devolverlas nunca al modelo.

### 3. Pedí lo que querés

```text
Implementá un módulo de aprobación de órdenes de venta para Odoo 19, usando el flujo SDD.
```

El agente toma `odoo-sdd-workflow` del catálogo de skills de la sesión y sigue el
protocolo. Si querés ser explícito — o asegurarte de que las instrucciones
completas se carguen — empezá tu mensaje con `/odoo-sdd-workflow`.

Para trabajo de configuración y datos sobre una instancia viva, pedí eso:

```text
Configurá empresa, impuestos y plan de cuentas en mi instancia de dev, y después
importá este clientes.csv — SDD funcional, entorno dev, nada de producción.
```

Eso selecciona `odoo-functional-sdd` (o `/odoo-functional-sdd` explícito) y el
modo de spec `functional` que sigue abajo.

---

## 🧭 Las cinco fases

| Fase | Qué pasa | Compuerta para salir |
|---|---|---|
| **CLARIFY** | Se registra la intención (`mode` create/bug, `licensed` y, opcionalmente, `translations` — ver abajo) y se responde la entrevista de seguridad: grupos, ACLs, record rules, justificación de `sudo()`, rutas públicas | `sdd_phase clarify` |
| **READ_SPEC** | Se asimila `spec.md`: contexto de negocio, criterios de aceptación numerados, restricciones, versión de Odoo objetivo. **Escribir código acá está prohibido.** | `APPROVED` + `mark_spec_loaded` |
| **ARCHITECTURE** | Modelos, vistas (incluidos tipos de vista extra y la vista search donde importan), reportes, matriz de seguridad y `test-plan.md` | `APPROVED` |
| **WRITE_CODE** | Se implementa el módulo con patrones Odoo pinneados por versión y su documentación OCA. En `mode=bug` la reproducción que falla va PRIMERO y se registra en `test-plan.md` como `red (evidence: …)` | gates estáticos en verde |
| **VERIFY** | Pirámide ascendente: estático → install/upgrade → RPC/datos → UI (Playwright) solo para flujos críticos | veredicto `PASSED` persistido |
| **FIX_LOOP** | Correcciones de causa raíz. 3 fallos consecutivos fuerzan diagnóstico de un consultor; 5 iteraciones fuerzan `BLOCKED` | veredicto honesto |

### ¿Se traducen las cadenas? (`translations`)

CLARIFY lo pregunta, junto a la de licencias, y la respuesta se graba **en la spec**
—no como ajuste global, igual que la edición—. Un mismo workspace puede tener un
proyecto de cliente que se entrega en español y un arreglo interno que no entrega idiomas.

```
sdd_phase operation=clarify spec_id=<id> mode=… licensed=… translations=["es_VE","es_PA"]
```

- **Omitir `translations` significa que el trabajo no se traduce.** Es una respuesta
  normal, así que a diferencia de `mode` y `licensed` NO es una compuerta fail-closed.
- Los códigos son **gettext** (`ll` o `ll_CC`: `es_VE`, `es_PA`, `pt_BR`, `fr`). Uno
  malformado se rechaza mientras todavía se puede corregir, en vez de convertirse en
  un fichero que el servidor nunca cargaría.
- Declarar idiomas hace que `odoo_i18n` entre en el plan y añade el criterio de
  aceptación correspondiente en `test-plan.md`: *"existe `i18n/<lang>.po` y ningún
  término exportado queda con `msgstr` vacío"* — que es lo que verifica
  `odoo_i18n operation=check`.
- `sdd_phase status` los reporta, así que "¿tradujimos esto?" se responde sin abrir
  `state.json`.

En ARCHITECTURE el agente también **pregunta** por lo que es barato decidir
temprano y caro descubrir tarde: **tipos de vista extra** más allá de form/tree
(incluida la **vista search** para cómo se busca un modelo — filtros
personalizados, favoritos), **reportes** (PDF vía `ir.actions.report`/QWeb, SQL,
CSV/XLSX, herramienta externa), **web tours** (onboarding, test o ninguno — con
el bundle de assets que los carga, porque un tour que ningún bundle carga nunca
se ejecuta) y **datos demo** (qué archivos y para qué). Cada uno se responde
explícitamente: "form + tree only", "no reports needed", "no tours needed",
"no demo data". Son decisiones **guía**: se registran en `## Views` /
`## Reports` / `## Tours` / `## Demo data` y se muestran como avisos en
`sdd_phase status`, no bloquean por diseño — el modelo de seguridad es la única
compuerta de contenido fail-closed. La API de tours por versión, el `HttpCase`
que los ejecuta y las trampas de los datos demo están en
`resources/references/tours-and-demo.md`; el ciclo de pruebas, las case classes
por versión y qué hace confiable a un test están en
`resources/references/odoo-tests.md`.

Artefactos por spec (todo en disco, reanudable):

```text
specs/<NNN>-<slug>/
├── spec.md · architecture.md · test-plan.md
├── verify-verdict.txt   # veredicto honesto persistido
├── state.json           # fase, fallos, iteraciones
├── kb.json              # decisiones, descartadas, blockers, diagnósticos
├── docs-report.md · security-report.md
└── handoff.md           # lo escribe sdd_handoff al cerrar la ejecución
```

---

## 🧩 El camino funcional (configurar e importar)

No todo trabajo de Odoo es código. Montar una empresa, sus impuestos, sus
usuarios y sus datos maestros es **configuración y datos**, y pasa sobre una
instancia viva — donde un clic equivocado no es un test fallido, es un registro
real. La misma máquina SDD lo cubre con otra fase en el medio y reglas de cierre
más estrictas.

```mermaid
graph TD
    C1["1 CLARIFY<br/>objetivo, instancia, ENTORNO"] --> R2["2 READ_SPEC<br/>spec.md, criterios, fuentes"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>proceso to-be, lotes, riesgos"]
    A3 -->|APPROVED| X4["4 APPLY_CONFIG<br/>descubrimiento + lotes aprobados"]
    X4 --> V5["5 VERIFY<br/>releer los registros, evidencia por criterio"]
    V5 -->|PASSED| D9(["runbook.md + handoff.md - DONE"])
    V5 -->|FAILED| F6["FIX_LOOP"]
    F6 --> X4

    style X4 fill:#1e1e2e,stroke:#f9e2af,stroke-width:2px,color:#cdd6f4
```

| | Corrida de desarrollo | Corrida funcional |
|---|---|---|
| Se elige en `CLARIFY` | `mode=create` o `mode=bug` | `mode=functional` |
| Fase del medio | `WRITE_CODE` (código del módulo) | `APPLY_CONFIG` (lotes contra la instancia) |
| Entregable | módulo + docs OCA | instancia configurada + `functional-runbook.md` |
| Skill | `odoo-sdd-workflow` | `odoo-functional-sdd` |

**Cómo llega un cambio a la instancia.** Nada se escribe "a ver qué pasa":

1. **Primero el descubrimiento**, con su **propia** aprobación: qué modelos, qué
   campos, cuántos registros. Leer no es mutar, pero un alcance aprobado es lo que
   impide que "mirar un poco" se convierta en un cambio.
2. **Plan**: el diseño se vuelve lotes. Cada uno declara destino y entorno, la
   versión y capacidades usadas, compañía y contexto, los criterios de aceptación
   que cubre, sus operaciones ordenadas, la identidad de los registros, las
   precondiciones, el resultado esperado, los riesgos, la recuperación y los pasos
   manuales.
3. **Aprobar**: la persona ve el lote exacto y lo aprueba por la compuerta nativa.
   El recibo queda atado a los hashes de la spec, el diseño, el plan y el lote —
   cambiá cualquiera y la aprobación deja de valer.
4. **Aplicar**: una operación a la vez, reverificando esos hashes, persistiendo el
   estado de cada operación **antes** de la llamada y **después** del resultado.
5. **Un resultado desconocido no es un reintento.** Un timeout después de una
   mutación puede significar que Odoo ya confirmó: la operación queda
   `indeterminate`, el lote se detiene y la corrida se estaciona hasta que una
   persona la reconcilie.
6. **Cerrar con honestidad**: `sdd_phase succeed` exige un `pass` explícito por
   criterio de aceptación, la revisión de seguridad es obligatoria, y también lo
   es el **runbook** (quién lo hace, en qué compañía, prerequisitos, la ruta de
   menú verificada, los pasos con sus etiquetas de campo, el resultado esperado,
   cómo comprobarlo y cómo deshacerlo) — diga lo que diga la política de
   documentación.

**El entorno se declara, nunca se asume.** `ODOO_SDD_ENVIRONMENT` en el destino
dice `dev`, `staging` o `production`. Un plan que declara un entorno distinto al
del destino se rechaza (`environment-mismatch`), un destino sin declarar lo pide
(`NEEDS_ENVIRONMENT`), y producción además exige un respaldo declarado y su
propia aprobación. Los cambios de riesgo alto se prueban primero en staging.

**Las importaciones van por el importador de Odoo, nunca por un parser propio:**

```text
odoo_import use=prepare file=... model=res.partner   # sube el archivo, con su propia aprobación
odoo_import use=preview   ...                        # lo que ODOO leyó: hojas, encabezados, muestra
odoo_import use=map       ...                        # una decisión por columna, sin huecos
odoo_import use=plan      ...                        # se vuelve un lote `apply`
odoo_functional operation=approve / apply            # el mismo camino de lotes, sin cambios
```

El contrato por versión es explícito (mayores 10–19: `file`/`import_id` + JSONP en
el endpoint viejo, `ufile`/`id` + JSON en el nuevo, `do`/`execute_import` para
aplicar), y una versión fuera de las familias verificadas se **rechaza** indicando
qué investigar en vez de adivinar. Un archivo que cambió después de la subida
invalida el mapeo; un `nextrow` en la respuesta significa que el importador se
detuvo a mitad del archivo y se reporta como parcial, nunca como éxito — y las
filas que contó no se reenvían. La cookie de sesión queda en `.sdd/session.json`
(modo 600) y fuera de todo resultado de tool.

> [!NOTE]
> El camino funcional necesita una instancia, y el importador necesita sesión web:
> corré `odoo_session` una vez antes de `odoo_import use=prepare`.

---

## 🧰 Las 19 tools

| Tool | Propósito |
|---|---|
| `odoo_connect` | Sonda la instancia: versión del servidor + autenticación. Reporte enmascarado; distingue los estados `NEEDS_SETUP` / `NEEDS_SECRET` / `DEFERRED` / `SKIPPED` (nunca pide secretos por chat). |
| `odoo_setup` | Onboarding: `check` (cascada + gitignore + modo de delegación), `interactive` (scaffold chmod 600 sin secreto), **`authorize`** (pide al DESARROLLADOR, vía aprobación nativa, un grant de conexión atado al url/db/usuario actual), **`revoke`** (elimina los grants), **`purge`** (primero muestra el plan y, con `confirm_destructive=true` + aprobación humana, borra solo el estado propio del plugin bajo `.sdd/`), `later`, `skip`, `reset`, `autonomy` (supervised \| autonomous, aprobado por un humano). Los secretos nunca se aceptan como parámetros. |
| `odoo_module` | `info` / `install` / `upgrade` sobre `ir.module.module` (`button_immediate_*`). Devuelve la salida o el traceback del servidor, redactado — el bucle de feedback cerrado. |
| `odoo_execute` | RPC genérico (`execute_kw`) contra la instancia: **cualquier método público de cualquier modelo se puede llamar**, y lo que necesita lo decide qué es. Las LECTURAS (`search`, `search_read`, `search_count`, `read`, `read_group`, `fields_get`, `name_get`, `name_search`, `default_get`, `exists`, `check_access_rights`, `check_access_rule`) corren libremente, con `fields`/`limit`/`order`/`offset` para proyección y paginado (un `offset` decimal o negativo se rechaza, nunca se recorta); las mutaciones CRUD (`create`/`write`/`unlink`) exigen `confirm_destructive=true` Y el modelo en `executeAllowlist`, y se journalizan para que el undo de datos pueda replicarlas; cada **acción de negocio** (`action_*`, `button_*`, `do_*`…, por ejemplo confirmar una orden) corre con `confirm_destructive=true` y **sin allowlist**, porque el plugin no puede replicar su efecto — nunca se journaliza, no existe undo para ella, y un timeout se reporta como un desenlace **INDETERMINATE** en vez de un fallo simple — la misma regla se aplica ahora a CRUD: un `create` cuya respuesta nunca llegó puede haberse confirmado ya, y reportarlo como un fallo reintentable es cómo un efecto se convierte en dos. Declará `precondition`/`postcondition` (la misma forma que usa un lote: `{domain, expect, count}`) para llevar una guarda de estado antes de la llamada y una prueba de estado después: si la precondition falla, no se envía nada; si la postcondition falla, la respuesta dice que la llamada SÍ se envió y que la instancia puede haber quedado a medio cambiar, y la mutación CRUD conserva su recibo del journal (id creado + preimagen) para que siga siendo compensable; sin ninguna, `OK` solo significa que el servidor aceptó la llamada. Un **método privado** (`_name`) se rechaza, y ese muro es de Odoo: `execute_kw` despacha por `get_public_method`, que responde `AccessError` para `_…`, `init`, `@api.private` y los nombres de atributos internos — así que leé el modelo en el código fuente (los repositorios nativo/custom los reporta `odoo_config mode=read`) y llamá al botón o la acción pública que lo envuelve (`sale.order._create_invoices` es privado; el wizard `sale.advance.payment.inv` expone el `create_invoices()` público). Para varias operaciones, o cuando se quiere una aprobación humana por corrida, usá un lote funcional con `kind: "method"`. `context` se reenvía tal cual — usalo para `allowed_company_ids`/`company_id` en instancias multi-company — y el servidor sigue aplicando su propia ACL. |
| `odoo_validate` | Validación LOCAL del módulo sin instancia: `__manifest__.py` + depends, los XML declarados existen (`views/*.xml` incluidos), `security/ir.model.access.csv` cuando hay modelos, y la metadata OCA que exige el `.pylintrc` del propio proyecto (`manifest-required-keys`, `manifest-required-authors`, más `license`/`version`, `website`, `README.rst`, `static/description/index.html`). Convenciones de vista: `position="replace"` sin `priority`, `t-esc` (usar `t-out`). Devuelve findings file:line, más el `module_dir` y la raíz del proyecto que resolvió (una ruta relativa se resuelve contra la carpeta de la sesión, nunca contra el cwd del proceso). |
| `odoo_errors` | Lee errores recientes del servidor (`ir.logging`) — el equivalente remoto de obtener los logs del entorno. |
| `odoo_session` | Mintea una sesión web sin contraseña (patrón `connect_as_user`) guardada en `.sdd/session.json` (chmod 600) para pruebas UI con Playwright. La cookie nunca se devuelve. |
| `sdd_phase` | Máquina de fases: `init`, `clarify`, `status` (resumen del logbook, directorio del spec y ubicación de los specs), `mark_spec_loaded`, `advance` (gates fail-closed + provenance `approval_source`), `fail` (escalera de fallos + veredicto FAILED), `succeed` (veredicto PASSED; se rechaza fuera de VERIFY/FIX_LOOP y salvo que cada AC que la SPEC declara tenga fila en `test-plan.md` con un `pass` explícito — un criterio omitido no es un criterio que pasa, y el veredicto guarda la huella de la evidencia que usó para que `DONE` rechace un veredicto que una edición dejó obsoleto), `rollback` (restaura un checkpoint DE ESTA SPEC y vuelve a la fase en la que trabaja su modalidad — WRITE_CODE, o APPLY_CONFIG en una spec funcional), `diagnose`, `waive` (registra — con aprobación nativa — que ESTA sesión puede trabajar sin spec: el "dejalo a tu criterio" del desarrollador, guardado textual en `.sdd/waiver.json`, reportado por `status` y por el handoff; `revoke: true` lo elimina y no necesita aprobación). |
| `sdd_checkpoint` | La superficie de rollback: `create` (snapshot del workspace, queda activo), `list`, `restore` (archivos y, con `restore_data=true` + `confirm_destructive=true`, las mutaciones de datos registradas: el undo corre bajo el contexto de compañía que usó la mutación, convierte formas de lectura en valores de escritura, marca cada operación para que un reintento no la compense dos veces, rechaza un journal de otro destino y reporta cada campo que no pudo restaurar; **siempre reporta** los archivos creados después del checkpoint y los borra solo con `remove_created=true`), `drop`, `journal`. |
| `odoo_docs` | Documentación de un módulo, usable **por sí sola** (sin spec, fase, checkpoint ni instancia), así que un módulo existente se puede documentar sin más: `check` (fragmentos OCA mapeados a Diátaxis, esquema de versión, changelog, `index.html`, docstrings, comentarios xpath, directiva OWL → ERROR/WARN con `file:line`), `plan`, `scaffold` (esqueletos create-only, nunca sobrescribe) y `report` (persiste `docs-report.md`; APPROVED solo si nada quedó en esqueleto). La entrada de changelog es obligatoria para cualquier cambio a un módulo ya publicado. |
| `odoo_security_scan` | Revisión de seguridad estática local (sin instancia): SQL concatenado, `eval`/`exec`/`pickle`, secretos hardcodeados, `sudo()` sin justificar, `auth="none"`, CSRF desactivado, `t-raw` en QWeb. Hallazgos con `file:line` + sugerencia; cualquier ERROR bloquea `DONE`. |
| `odoo_manual` | Manuales de usuario narrados y construidos sobre evidencia. **`canon`** lee de la instancia el canon funcional de un modelo (vistas, botones del header y los estados que los revelan, campos requeridos, páginas del notebook, acciones, menús, reglas de acceso, grupos, sitios de `raise UserError`/`ValidationError` y de `_sql_constraints`) y lo persiste con una huella: el manual solo puede describir lo que el canon dice. **`plan`** convierte el canon en un storyboard (flujos, pasos ordenados, una frase por paso): volver a ejecutarlo FUSIONA, así la narración, las capturas y la prosa ya escritas sobreviven. **`lint`** contrasta el storyboard y la prosa renderizada contra el canon (`CANON-STALE`, `TARGET-UNKNOWN`, `PLACEHOLDER-UNBOUND`, `ACTS-MISSING`, `RECOVERY-MISSING`, patrones que delatan IA en español e inglés → ERROR/WARN con la línea exacta). **`explore`** devuelve el snapshot ARIA de una pantalla real, así no se adivina ningún xml id ni etiqueta. **`capture`** maneja un navegador: los pasos de solo lectura siempre corren, y los pasos de acción (`new`/`fill`/`select`/`press`/`click`) exigen `allow_actions=true` — que el guard trata como una mutación. **`render`** escribe el manual y una página de Apps (create-only salvo `overwrite=true`). **`import`** convierte una grabación `codegen` de Playwright en un flujo y rechaza cualquier grabación que lleve credenciales. Los manuales viven donde diga Ajustes (`manualsMode`: `specs` junto al spec (por defecto), `docs` bajo `manualsDir`, `module` como fragmentos OCA bajo el `readme/manual/` del módulo); el trabajo standalone sin spec nunca se bloquea por eso. **`canon`** recibe la entrevista — `as_user` (para quién es el manual), `admin`, `module`/`modules` y `groups` — y la registra: el inventario cubre además TODOS los menús y TODAS las acciones del módulo (la acción que abre un menú, aunque sea nativa), y cada modelo llega con sus campos (etiqueta, tipo, obligatorio, relación/selección, grupos), sus botones de formulario y de lista, los estados del statusbar y sus filas de acceso por grupo. **`plan`** abre un flujo por menú y uno por cada recorrido de grupo, y siembra una lista `docs` con una celda vacía por campo y por botón. **`lint`** reporta `MODULE-COVERAGE`, `INTERVIEW-MISSING`, `FIELD-COVERAGE`, `BUTTON-COVERAGE`, `WORKFLOW-COVERAGE` y `GROUP-COVERAGE` mientras quede sin cubrir un menú, una acción, un campo, un botón, el workflow o un grupo — un `skip` con motivo es cobertura documentada, y `ROLE-UNVERIFIED` avisa porque una captura corre como el usuario conectado. **`render`** añade una tabla por modelo (tipo, obligatorio, relación, grupos y tu frase) y una sección por grupo de acceso. `odoo_docs` añade una sección `## User manual` al veredicto de documentación en cuanto existe un storyboard — y no la toca cuando no hay ninguno. |
| `odoo_video` | Documentación en VIDEO narrado de flujos, standalone junto a `odoo_manual` (nunca escribe `MANUAL.md`). **`plan`** redacta el storyboard desde el canon de la instancia — o, con `from_manual=<slug>`, copia el `canon.json` + `storyboard.json` de un manual existente, así el manual narrado se vuelve el guion del video sin releer la instancia (import-once: se niega a pisar un storyboard de video, porque las ediciones del lado del video se perderían). **`voice`** narra la frase `procedure` de cada paso con un comando TTS que TÚ configuras (`ttsCommand`, ejecutado sin shell; placeholders `{text}`/`{textFile}`/`{voice}`/`{out}`), un `voice/<flow>/<step>.wav` por paso narrado — idempotente: un `.wav` existente jamás se regenera (bórralo para rehacerlo), y `voice.json` registra voz + idioma. **`capture`** corre el flujo en un navegador real con la grabación SIEMPRE activa: `video/raw/<flow>.webm` + marcas de tiempo `startMs`/`endMs` por paso + PNG fijos, mismas compuertas que el manual (los pasos de acción exigen `allow_actions=true` Y `confirm_destructive=true` sobre una base desechable; `from_step` reanuda tras un fallo). **`assemble`** corta el webm por paso, congela el frame (tpad) hasta la duración de la narración, muxa `video/<flow>.mp4` (h264 + aac, faststart) con ffmpeg, y SIEMPRE entrega `.srt` + `.vtt` — la vía de exportación a plataformas de video externas; `captions=true` opcional los quema en pantalla (drawtext). El webm crudo se conserva: re-ensamblar con otra voz u otro ritmo jamás exige re-capturar. El idioma se fija en `plan` (param > manual > `documentationLanguage` > `en_US`); sin TTS configurado → `NEEDS_TTS` con voces de ejemplo por idioma (piper-tts offline, edge-tts en la nube — un TTS en la nube envía el texto de la narración a su proveedor, decisión explícita del desarrollador). |
| `odoo_tests` | La mitad de pruebas del pipeline, usable **por sí sola** (sin spec, fase, checkpoint ni instancia): `check` escanea las pruebas Python por lo que un log de instalación no puede mostrar — un `tests/*.py` que `tests/__init__.py` nunca importa (Odoo solo carga lo importado, así que la prueba nunca corre), una clase que no es un caso de test de Odoo, un `test_*` que no afirma nada o afirma algo trivialmente verdadero, una excepción tragada, `time.sleep`, skips explícitos, `cr.commit()` dentro de una prueba (rompe el rollback por prueba), `SavepointCase` en 17+, un `HttpCase` sin `start_tour` — con la densidad de aserciones como métrica informativa. `plan` mapea cada criterio de aceptación de la spec a la prueba que lo menciona (por AC id o por el `REQ-<AREA>-NN` de su requisito) y lista los criterios sin prueba. `scaffold` escribe un **stub RED por criterio sin cubrir** (el cuerpo lanza `NotImplementedError`, nunca un pass vacío), create-only e idempotente, agregando el import para que el archivo realmente cargue. |
| `sdd_handoff` | Escribe `specs/<id>/handoff.md` (fase final, veredicto, decisiones, blockers, checkpoints, el journal de datos **completo** de la spec, config efectiva, próximos pasos) al cerrar la ejecución. Solo lista los checkpoints de ESTA spec (los de otras specs que comparten el contenedor se cuentan, no se nombran) y resume la allowlist global de mutaciones en vez de volcarla. |
| `odoo_config` | Lee o actualiza la configuración persistente y responde **"¿en qué proyecto estoy?"**: la raíz resuelta, su procedencia (cwd de la sesión / configurada / cwd del proceso), la base de specs, el directorio de spec efectivo y el archivo de configuración en uso. |
| `odoo_i18n` | Traducciones con los **modelos propios de Odoo**: `base.language.export` aporta los términos traducibles y `base.language.install` activa el idioma. `export` escribe `<módulo>/i18n/<lang>.po` (creando la carpeta `i18n/` si falta) y **fusiona** con el fichero existente — una traducción escrita nunca se reemplaza por lo que diga el export, los términos nuevos se añaden y los que el módulo ya no tiene se conservan como `#~` en vez de borrarse. `status` informa la cobertura de cada idioma y `check` falla mientras quede una entrada sin texto o sin el comentario `#. module: <nombre>` que exige `oca-checks-po` de OCA (lo escribe el export; el POT de Odoo no). No traduce: el export da los identificadores y el texto lo escribe una persona. Consciente de la versión (Odoo 16 es el corte, el mismo que eliminó `ir.translation`). |
| `odoo_import` | Preparación de una importación CSV/XLS/XLSX por el importador **propio de Odoo** (`base_import`), nunca con un parser de este plugin: `prepare` sube el archivo autorizado con la sesión web y su propia aprobación, `preview` informa lo que Odoo lee (hojas, encabezados, una muestra acotada, los campos importables), `map` registra una decisión por columna y `plan` lo convierte en un lote `apply` — que `odoo_functional` aprueba y ejecuta como cualquier otro, así que esta tool nunca aplica una importación por sí sola. Una respuesta JSONP se parsea como dato (jamás se ejecuta), la cookie de sesión no sale del plugin, y una versión fuera de las familias verificadas se rechaza indicando qué investigar. |
| `odoo_functional` | El ejecutor de lotes del camino funcional: `plan` (valida y guarda un lote fail-closed), `approve` (aprobación humana nativa atada a los hashes de spec, diseño, plan y lote), `apply` (lo ejecuta operación por operación, persistiendo cada estado antes y después de la llamada, y lo registra como **aplicado sólo después de releer el estado que la operación declaró** — una llamada que responde pero deja los registros intactos es un fallo, no un éxito), `inspect` (descubrimiento solo-lectura bajo su propio alcance), `status`, `reconcile` (decide un resultado que volvió desconocido), `verify` (evidencia por criterio de aceptación) y `compensate` (arma el lote de deshacer desde el journal). El entorno declarado condiciona la corrida y producción exige además un respaldo declarado; mientras un lote corre, toda otra vía de mutación queda denegada. |

---

## 🎛️ Modo de delegación

El pipeline empieza preguntando cuánto delegar — se registra una vez por proyecto
con `odoo_setup mode=autonomy decision=...`:

| Modo | Quién responde las compuertas | Cómo termina |
|---|---|---|
| **Supervisado** (default) | vos, en cada fase con compuerta | aprobás, o la ejecución se detiene |
| **Autónomo** | un agente **proxy humano** (`resources/roles/human-proxy.md`) que solo emite un `APPROVED` fail-closed de inicio de línea o `NEEDS_REVISION` | `create_goal` corre rondas sin atención hasta `DONE` o `BLOCKED` |

> [!TIP]
> En modo autónomo los frenos siguen armados: `stop.md`, el techo de iteraciones
> y la escalera de diagnóstico se mantienen, y `BLOCKED` es la única forma en que
> la ejecución cita a un humano. Los grants de conexión **no** los cubre el
> cambio de modo: un humano igual autoriza la instancia una vez.

---

## 🧠 Capas de Context Engineering

| Capa | Componente |
|---|---|
| **identity** | `resources/roles/*.md` — roles arquitecto, desarrollador, QA, consultor, proxy humano, security-reviewer y documentación (rol + límites). Ambos skills declarados declaran `resources/` como su `resourceBase`, así que cada ruta `roles/…` y `references/…` resuelve desde la base que el host le entrega al modelo |
| **odoo_connection** | `odoo-client.ts` — JSON-RPC auth, `execute_kw`, sesión |
| **executors** | `odoo_module`, `odoo_execute`, `odoo_validate`, `odoo_errors` |
| **schemas** | plantillas con secciones obligatorias; `transition()` rechaza una fase cuyo entregable carezca de ellas |
| **knowledge** | skills de patrones Odoo pinneados por versión (delegados, verificados por el skill) |
| **skills** | `SKILL.md` — el flujo de orquestación en 5 fases, auto-registrado con el host en `apply()` |
| **logbook** | `kb.json` — decisiones, descartadas, blockers; se lee antes de proponer |
| **audit** | `.sdd/audit.jsonl` — log de actividad append-only sanitizado, escrito por un listener global `tools/result`: siempre las tools de este plugin y todo rechazo de política; con `auditAllTools` encendido, además cada llamada de cualquier otra tool. Una tool que escribe archivos se registra con ruta + tamaño + sha256, y el archivo rota a los 16 MB conservando una generación |
| **rollback** | `.sdd/checkpoints/<id>/` — manifest + snapshot de archivos + journal de datos, restaurable por spec |
| **security** | reglas de `odoo_security_scan` + el rol `security-reviewer` + la entrevista de seguridad obligatoria en CLARIFY |
| **test** | `tests/smoke.mjs` — suite de invariantes sin instancia (máquina de estados, seguridad, guard de política, formas RPC, raíz/disposición de specs, contrato de host con Cordis real) + `tests/client.mjs` — contrato del bundle de navegador y render del panel de ajustes |

---

## 🛡️ Seguridad, rollback y trazabilidad

El pipeline asume que el agente se va a equivocar en algún momento, así que cada
camino de mutación tiene vuelta atrás y forma de probar qué pasó.

- **Las credenciales no son consentimiento.** Antes de que ninguna tool abra un
  socket hacia la instancia, un HUMANO debe haber aprobado ese destino exacto.
  `odoo_setup mode=authorize` pregunta por el seam de aprobación nativo del host y
  solo el outcome `allowed-once` guarda un recibo en `.sdd/grants.json` (0600,
  gitignored). El recibo está atado a un fingerprint de `url + db + usuario`:
  cambiar cualquiera de los tres lo invalida; `mode=revoke` lo elimina. Sin recibo
  vigente no se entrega ningún cliente, así que un `.env` configurado no se usa en
  silencio. En modo AUTÓNOMO no hay answerers, así que la ejecución reporta
  `NOT AUTHORIZED` y se detiene — que es justamente el punto.
- **El modelo no puede relajar su propia política.** Cambiar la allowlist o las
  guardas (`odoo_config mode=set`) y cambiar el modo de delegación
  (`odoo_setup mode=autonomy`) exigen aprobación nativa cada uno.
- **Sin cambio sin spec — o tu waiver explícito.** Sea cual sea la petición
  ("ahora cambiá también X" en el mismo chat), un cambio necesita una spec en una
  fase de escritura (`WRITE_CODE`, `VERIFY`, `FIX_LOOP`, `APPLY_CONFIG`) o un
  waiver que hayas aprobado para ESA sesión. La compuerta cubre ambas
  superficies: las mutaciones de instancia (`odoo_execute`, `odoo_module`,
  `odoo_import prepare`) y las ediciones de código a través de las tools
  `write`/`edit` del host. Escribir los propios documentos de spec (`specs/`,
  `.sdd/`) nunca se bloquea — si no, la salida también quedaría bloqueada. Un
  cambio pequeño es una spec pequeña: `mode=bug`, un criterio de aceptación, sin
  entrevista de diseño. Si preferís no especificarlo, `sdd_phase
  operation=waive detail="..."` registra tus palabras, exige tu aprobación y
  cubre solo esta sesión: el próximo chat arranca otra vez bajo la política, y
  cada cambio hecho bajo ella igual queda en la KB como decisión.
  `requireSpecForChanges: false` es el interruptor humano. **Agujero honesto:** el
  guard ve las llamadas a las tools del host, así que un cambio hecho a través de
  `bash` (un heredoc, `sed -i`) no queda gateado — el pipeline es el camino, no
  una cárcel.

  **Dónde se pregunta** (`specPolicyScope`, por defecto `"odoo"`). Exigir una spec
  solo tiene sentido donde hay trabajo Odoo, y el guard comprobaba una sola cosa:
  si la ruta caía dentro de la raíz de la sesión. Por eso un plugin instalado a
  nivel de perfil bloqueaba escrituras en carpetas que no tienen nada que ver con
  Odoo. Con `"odoo"`, una **edición de archivo** solo se gatea donde el directorio
  muestra alguna señal de trabajo Odoo:

  - `.sdd/` con estado real del plugin (config, specs, checkpoints, un waiver) —
    **no** un `.sdd/` que solo tenga `audit.jsonl`, que el listener de auditoría
    escribe en *toda* llamada y que por tanto significa "aquí corrió algo", no
    "esto es un proyecto Odoo";
  - `__manifest__.py` en la raíz — el directorio ES un módulo;
  - `__manifest__.py` un nivel abajo — el directorio contiene módulos;
  - una tool `odoo_*` ya usada contra esa raíz en esta sesión.

  Una **mutación de instancia siempre se gatea**: es una llamada `odoo_*`, que es
  la evidencia por sí misma. `stop.md` sigue deteniendo todo, adoptado o no,
  porque es una parada de emergencia y no una cuestión de alcance. `"everywhere"`
  restaura el comportamiento anterior, y el rechazo ahora **nombra la señal** que
  armó la política en vez de dejarte adivinando.

  **Tres salidas, y las tres funcionan.** (1) una spec pequeña para este cambio,
  (2) continuar la spec que ya lo cubre, (3) pedirle al desarrollador el waiver —
  `sdd_phase operation=waive detail="..."`. La tercera exigía un `spec_id`, lo que
  la hacía inalcanzable justo en el caso para el que existe: un directorio sin
  specs, donde había que crear una para decir que no querías una. El waiver es por
  proyecto y por sesión, y `spec_id` ahora es opcional para él (y para
  `waive revoke=true`) mientras que toda otra operación lo sigue exigiendo.
- **Checkpoint antes de mutar.** Con `requireCheckpointBeforeMutation` activo
  (default), las mutaciones de `odoo_execute` se deniegan hasta que
  `sdd_checkpoint create` haya hecho snapshot de la spec activa — y se deniegan
  directamente antes de `WRITE_CODE`. Los snapshots omiten symlinks (`lstat`) y
  nunca copian `.env` ni material de claves.
- **Guard fail-closed.** Un fallo interno del guard deniega con un motivo visible
  en vez de dejar pasar la llamada.
- **Un archivo de credenciales no se escribe mientras git todavía pueda verlo
  (fail-closed).** Antes de crear un `.env`, el plugin le pregunta a git mismo
  — `git check-ignore` para las reglas y `git ls-files` para el índice — y se
  niega con `status: needs-gitignore` cuando una ruta protegida falta en el
  `.gitignore` **o ya está trackeada**, y en ese caso el único arreglo que nombra
  es `git rm --cached`. Una regla de ignore nunca protege a un archivo que está
  en el índice, y ese hueco es exactamente cómo un `.sdd/.env` con contraseña
  viva llegó una vez a una rama remota. Donde no hay git, el fallback lee el
  texto del `.gitignore` y lo declara, en vez de fingir una prueba. Se desactiva
  por proyecto con `requireGitignoreBeforeCredentials: false`.
- **Rollback de archivos.** `sdd_checkpoint restore` devuelve los archivos del
  snapshot tal cual eran; `sdd_phase rollback` retorna la spec a `WRITE_CODE` con
  el fallo registrado, para reiniciar desde un estado conocido. Un checkpoint
  captura el árbol del proyecto, así que con la disposición **central** de specs
  los documentos de spec (que viven fuera del proyecto) no forman parte de él a
  propósito: la spec es la fuente de verdad inmutable, no código a revertir.
- **Rollback de datos (best effort, y honesto al respecto).** Cada
  `create`/`write`/`unlink` vía `odoo_execute` guarda su pre-imagen en el journal
  del checkpoint, marcada con la base de datos **y** el destino (url+db+usuario)
  donde se aplicó; `restore restore_data=true confirm_destructive=true` la
  reaplica en orden inverso y se niega ante un journal de otro destino. El replay
  corre bajo el contexto de compañía que usó la mutación, convierte formas de
  lectura en valores de escritura (many2one, x2many), marca cada operación a
  medida que la compensa para que un reintento no la repita, y reporta los campos
  que no pudo restaurar (contenido binario, campos readonly o no almacenados).
  Los registros re-creados reciben ids NUEVOS — el informe lo dice. Cubre los
  datos escritos a través del plugin — **no** los efectos de un install/upgrade de
  módulo, que no se revierten a nivel de base de datos.
- **El restore reporta el drift.** `restore` siempre lista los archivos creados
  *después* del checkpoint, así que nada queda en silencio; `remove_created=true`
  los borra (solo dentro de las raíces del snapshot) para igualar el snapshot.
- **La documentación es una compuerta, no una nota al pie.** ARCHITECTURE
  registra la decisión en `## Documentation`, WRITE_CODE produce los fragmentos
  OCA + el `index.html` de Apps + la entrada de changelog obligatoria, y `DONE`
  queda gateado por `documentationPolicy` (`required` por defecto; `optional` y
  `off` disponibles). Lo que el plugin no puede hacer, lo dice:
  `gen-odoo-readme`, `towncrier`, Ruff y pylint necesitan shell, así que los
  fragmentos son la fuente de verdad y compilar `README.rst` sigue siendo tu paso.
- **Ciclo de vida: el plugin posee su estado y puede devolverlo.** `odoo_setup
  mode=purge` imprime un plan (lo que posee y lo que deliberadamente conserva) y
  borra solo con `confirm_destructive=true` más aprobación humana nativa. Nunca
  toca `.env` (tus credenciales), `stop.md` (tu freno) ni `specs/` (tus
  documentos).
- **Estado durable.** El estado del pipeline, el KB, los veredictos, los grants y
  el journal se escriben con reemplazo atómico; un archivo corrupto se pone en
  cuarentena junto al original en vez de sobrescribirse, y `sdd_phase status`
  reporta la recuperación.
- **Trazabilidad.** `.sdd/audit.jsonl` registra cada llamada con resultado
  (`ok` / `error` / `denied`), duración y fase; `sdd_phase status` imprime el
  logbook; `sdd_handoff` congela la ejecución completa en `handoff.md`. El cuerpo
  de un archivo no se copia al log: se registra como `{bytes, sha256}`, y `bash`,
  `write` y `edit` solo se registran con `auditAllTools` encendido.
- **Frenos de emergencia.** `stop.md` (en `.sdd/stop.md` o
  `specs/<activa>/stop.md`) detiene todas las tools; los techos de iteración y la
  escalera de diagnóstico llevan a `BLOCKED` en vez de iterar sin fin.

---

## 🔐 Postura de seguridad y red

- **Sin teléfono a casa.** Las ÚNICAS llamadas de red salientes del plugin van a
  la URL de la instancia que pusiste en `.env`. Sin telemetría, sin comprobaciones
  de actualización, sin endpoints de terceros.
- **Guard de transporte (fail-closed).** `http://` solo se acepta para hosts
  loopback (`localhost`, `127.x`, `::1`, `*.localhost`); cualquier otro destino
  debe ser `https://`, o la carga de credenciales se rechaza — http plano a un
  host remoto enviaría la API key en texto claro.
- **Contención del secreto.** El secreto se lee una sola vez en `credentials.ts` y
  solo se inyecta en los parámetros RPC. Toda salida de tool pasa por redacción de
  dos capas (secreto conocido + formas genéricas `password=` / `Bearer` /
  `api_key=` / `session_id`) y enmascaramiento del home (`/home/usuario/…` →
  `~/…`) antes de mostrarse al modelo o persistirse en el KB.
- **Las cookies de sesión nunca llegan al modelo.** `odoo_session` escribe la
  cookie en `.sdd/session.json` (chmod 600) y devuelve solo la ruta. El motor de
  captura ni siquiera escribe una: autentica la sesión web en memoria y mantiene
  la cookie ahí para el contexto del navegador.
- **El navegador de captura queda bloqueado al origen de la instancia.** Toda
  petición que salga de la URL base de la instancia se aborta, así que una página
  que intente llegar a un host de terceros falla en vez de cargar. La captura es
  solo-lectura por defecto: los pasos que solo observan (`goto`, `menu`, `wait`,
  `assert`, `explore`, `screenshot`, `confirm`) siempre corren; los pasos que
  actúan (`new`, `fill`, `select`, `press`, `click`) exigen `allow_actions=true`
  Y `confirm_destructive=true`, el guard clasifica esa llamada como mutación
  (spec en una fase de escritura o un waiver) — y **un clic no tiene entrada de
  compensación**: a diferencia de una llamada CRUD, el plugin no puede deshacer
  lo que hizo la UI. Capturá contra una base desechable.
- **Una grabación se lee como dato, y las credenciales se rechazan.** `odoo_manual
  operation=import` parsea el texto de `codegen`; una grabación que lleve un campo
  de contraseña, un header `Authorization` o una cookie se rechaza por línea, y el
  valor nunca se devuelve en el eco.
- **Sin scripts de instalación más allá del build.** Los únicos hooks de
  ciclo de vida son `prepare`/`prepack`, que compilan `src/` hacia el `lib/` que
  se distribuye y no hacen nada más — sin red, sin `postinstall`, sin shell. Los
  dos tienen políticas de fallo opuestas a propósito: **`prepare` (que corre en
  una instalación) nunca rompe una** — sin las devDependencies o sin los peers
  opcionales del host dice qué no pudo verificar y emite sin typecheck para que
  el plugin igual cargue —, mientras que **`prepack` (que corre en una
  publicación) se niega a empaquetar un build que no pudo typechequear**.
  `prepack` es además lo que garantiza que un tarball publicado nunca salga sin
  el entry point que su `main` promete (el fallo fue real: un clon limpio
  empaquetó 39 archivos y cero bajo `lib/`; también lo fue su secuela, un
  `prepare` que typechequeaba durante `npm install` y tumbaba todos los jobs del
  CI antes de que corriera el paso que instala esos peers).
- **Requisito de host declarado dos veces**, siguiendo las convenciones de
  descubrimiento de dsh-market: `engines.dsh` y rangos peer opcionales lockstep
  sobre `@deepseek-ai/{cordis,dsh-tools,schemastery}`. Ante un host sin el
  servicio `tools`, el plugin se niega a montar con un error explícito en lugar de
  arrancar roto.

---

## 🖥️ Soporte de plataformas

El plugin corre donde corre DSH y declara soporte para **Linux, macOS y
Windows** — una afirmación que la matriz de CI verifica en lugar de asumir
(`ubuntu-latest` **y** `windows-latest`, en Node 20, 22 y 24).

| Aspecto | Comportamiento |
|---|---|
| Rutas | `module_dir` y una raíz explícita aceptan formas POSIX (`/opt/odoo`) y Windows (`C:\odoo`, UNC); una ruta absoluta nunca se concatena bajo la raíz del proyecto. |
| Permisos del `.env` | Se pide modo solo-propietario (0600) y se **vuelve a comprobar después del `chmod`**: en un sistema de archivos que no puede expresar los bits de modo (Windows, FAT/exFAT, algunos montajes) el plugin informa "modo solo-propietario solicitado" y agrega una nota, en vez de fingir que el archivo es privado. En un sistema POSIX real, un modo abierto que no se puede corregir sigue rechazándose. |
| Escrituras atómicas | Los archivos de estado se escriben en un temporal hermano y se renombran en su lugar, reintentando `EPERM`/`EACCES`/`EBUSY` con espera acotada: el caso en que Windows rechaza el renombrado porque un editor, indexador o antivirus mantiene un handle abierto. |
| Raíz del proyecto | Se resuelve en cada llamada desde la **carpeta de la sesión**; el cwd del proceso es solo el último recurso y se informa como `LAST RESORT`. |
| Symlinks | Un checkpoint nunca sigue un symlink fuera del árbol; la suite omite sus aserciones de symlink donde el sistema operativo o los privilegios del usuario no permiten crearlos, y lo dice en lugar de pasar en silencio. |

### 📁 Dónde viven los specs

La raíz del proyecto es la carpeta abierta en la sesión actual, así que no es una
configuración global del plugin. Los documentos de spec la siguen:

| Disposición | Ruta | Elegila cuando |
|---|---|---|
| **project** (default) | `<raíz>/<specsDir>/<specId>` | los specs deben viajar con el código |
| **central** | `<specsRoot>/<projectSlug>/<specId>` | tenés muchos repos de módulos y querés un solo lugar donde buscar |

En la disposición central cada proyecto tiene su propia subcarpeta con un archivo
`.dsh-project-root`, más un sufijo hash si dos proyectos comparten el nombre de
carpeta — una carpeta ajena nunca se adopta. `.sdd/` siempre queda en el proyecto.

```text
<raíz del proyecto>/
├── .sdd/                       # del plugin, gitignored
│   ├── .env                    # credenciales (chmod 600)
│   ├── config.json             # configuración del proyecto
│   ├── grants.json             # recibos de autorización humana
│   ├── session.json            # cookie de Playwright
│   ├── audit.jsonl             # actividad de tools, sanitizada (digests para cuerpos)
│   ├── setup-state.json        # decisión de onboarding + delegación
│   ├── active/<sessionId>.json # puntero de ESTA sesión (spec, fase, checkpoint)
│   ├── active.json             # última spec del proyecto (pista; ver adoptProjectPointerHint)
│   └── checkpoints/<id>/       # manifest + snapshot + journal de datos
└── specs/<NNN>-<slug>/         # o la carpeta central
```

**El puntero de spec es de la SESIÓN, no del proyecto.** Cada chat escribe
`.sdd/active/<sessionId>.json`, así que dos sesiones abiertas sobre el mismo
proyecto ya no se pisan ni pueden autorizar cambios contra la spec de la otra.
`.sdd/active.json` queda solo como *última spec del proyecto*: una sesión sin
puntero propio la adopta como **pista** cuando es reciente (< 12 h) y la spec
sigue existiendo. Si preferís alcance estricto —cada sesión nace sin spec hasta
que la declare con `sdd_phase status spec_id=…`— poné
**`adoptProjectPointerHint: false`** en el panel o con `odoo_config`. Los
*snapshots* de checkpoint siguen siendo del proyecto; cuál es tu objetivo de
rollback es de cada sesión, y al borrar uno se limpia la referencia en todos los
punteros que la tenían.

> [!TIP]
> Con varios proyectos abiertos, leé la línea `Project root: … [procedencia]` que
> trae cada resultado de tool, o preguntá con `odoo_config mode=read`: devuelve la
> raíz resuelta, su procedencia y el directorio de spec efectivo.

---

## ⚙️ Configuración

Abrí **Ajustes → Odoo SDD** en la Web UI. Todo es editable ahí, más algunos
presets para copiar y pegar donde los necesites.

> **Requisito del host:** el plugin monta solo sobre **dsh >= 0.2.2-rc.1** (eso es
> `engines.dsh`, el único campo de versión que el host aplica). El panel de Ajustes
> necesita **dsh >= 0.2.0-rc.1**: ese host
> eliminó el servicio `settingsScope` al que el panel se enlazaba, y ahora compone el
> formulario a partir del propio esquema `Config` del plugin (entrada `odoo-sdd`). En un
> host anterior la sección no aparece y el plugin lo dice en la consola; las tools y el
> pipeline siguen funcionando, y `odoo_config mode=read|set` edita los mismos valores
> desde la conversación.


<p align="center">
  <img src="assets/settings-panel.jpg" alt="Ajustes → Odoo SDD: dónde viven las specs, quién aprueba las fases, licencia y el allowlist de mutaciones" width="640" />
</p>

```yaml
# ~/.dsh/profiles/<perfil>/cordis.patch.yml (opcional: los mismos campos, como parche)
- insert:
    - id: odoo-sdd
      config:
        specsMode: project      # project | central
        specsRoot: ''           # carpeta absoluta cuando specsMode=central
        specsDir: specs         # carpeta dentro del proyecto cuando specsMode=project
        executeAllowlist: []    # modelos que odoo_execute puede crear/modificar/borrar
        methodAllowlist: []     # pares "modelo.metodo" que un lote funcional puede llamar (cualquier modelo)
                                # el RPC ad-hoc no necesita lista: cualquier método público + confirm_destructive
        communityRepoUrl: https://github.com/odoo/odoo
        enterpriseRepoUrl: https://github.com/odoo/enterprise
        communityRepoPath: ''   # path local del SO; si se define, ANULA communityRepoUrl
        enterpriseRepoPath: ''  # igual para enterprise
        odooApi: auto           # auto | json2 | jsonrpc (auto repliega ante un 401)
        autonomy: supervised    # supervised | autonomous
        licensed: community     # community | enterprise (OCA se busca siempre)
        requireCheckpointBeforeMutation: true
        requireGitignoreBeforeCredentials: true   # niega el .env mientras git lo vería
        requireSpecForChanges: true     # todo cambio necesita una spec en una fase de escritura (o un waiver)
        specPolicyScope: odoo           # odoo (solo donde se detecta trabajo Odoo) | everywhere
        securityReviewRequired: true
        securityInterviewRequired: true
        auditAllTools: false    # registra además TODA otra llamada (cuerpos como digest)
        maxCheckpoints: 5
        documentationPolicy: required   # required | optional | off
        documentationLanguage: ''       # vacío = inglés salvo que el proyecto diga otra cosa
        manualsMode: specs              # specs (junto al spec) | docs (bajo manualsDir) | module (readme/manual/)
        manualsDir: docs/manuals        # se usa solo cuando manualsMode es docs
        videosDir: docs/videos          # donde odoo_video escribe los workspaces <slug>/ (dentro de la raíz del proyecto)
        ttsCommand: ''                  # SOLO video narrado. TTS externo, sin shell; placeholders
                                        #   {text} {textFile} {voice} {out}. Ejemplos:
                                        #   edge-tts (nube): edge-tts --voice {voice} --text {text} --write-media {out}
                                        #   piper  (offline): piper --model {voice} --output_file {out}   (usa {textFile})
        ttsVoice: ''                    # se pasa como {voice}; voces ejemplo: es-ES-ÁlvaroNeural (edge) / es_ES-sharvard-medium (piper)
        ffmpegPath: ffmpeg              # ensamblado de video (ffmpeg del sistema, 4.x+; 7.x verificado) y ffprobe junto a él
```

> [!IMPORTANT]
> Hay dos almacenes de configuración y gana el más específico: **Ajustes**
> (global del usuario, `~/.dsh/settings.yaml`) y el **`.sdd/config.json`** del
> proyecto (lo escribe `odoo_config mode=set`, por proyecto). Si una clave parece
> ignorada después de cambiarla en el panel, el archivo del proyecto la está
> fijando — `odoo_config mode=read` reporta los valores efectivos.

---

## 🤖 Experiencia del modelo

El agente ve 19 tools con descripciones autocontenidas. El flujo típico:
`sdd_phase init` → entrevista de seguridad + `odoo_connect` → fases con compuerta
mediante `APPROVED` → `sdd_checkpoint create` → código → `odoo_security_scan` →
`odoo_module install` → si hay traceback, `odoo_errors` + `sdd_phase fail` (puede
exigir diagnóstico) → corregir (o `sdd_phase rollback`) → re-verificar →
`sdd_phase succeed` → `sdd_handoff` → `DONE`. Las respuestas son texto accionable:
tracebacks del servidor, razones de rechazo de compuertas e instrucciones de
remediación.

El skill del flujo se registra al montar el plugin, así que su **nombre y
descripción** aparecen solos en el catálogo de skills de cada sesión. Las
instrucciones completas se cargan cuando el modelo lo selecciona (el catálogo son
solo resúmenes) o cuando escribís `/odoo-sdd-workflow`.

---

## ⚠️ Limitaciones conocidas y trabajo diferido

- **Tests remotos** — sin acceso shell a la instancia no hay forma de correr
  `--test-enable`; la segunda capa de verificación es pruebas vía RPC/UI.
  Pendiente: una tool opcional `odoo_run_tests` si exponés un runner.
- **El rollback de datos es best effort** — las pruebas escriben en la base
  conectada y no hay clonación efímera (decisión de diseño: la base la provee y
  gestiona el desarrollador). `sdd_checkpoint` revierte los datos escritos vía
  `odoo_execute`, pero un install/upgrade de módulo **no** se revierte a nivel de
  base de datos. Usá una base desechable.
- **Alcance del scan de seguridad** — `odoo_security_scan` es basado en reglas
  sobre el texto fuente (sin AST ni taint tracking), así que atrapa los errores
  típicos de Odoo, no todos; complementa la revisión humana, no la reemplaza.
- **La política de cambios cubre las llamadas a tools, no el shell** —
  `write`/`edit` y las tools de Odoo quedan gateadas; un archivo escrito a
  través de `bash` (un heredoc, `sed -i`) no, porque el host no expone ninguna
  inspección posterior de lo que escribió un comando de shell. La política hace
  que el pipeline sea el camino de menor resistencia y la excepción visible; no
  es un sandbox.
- **Multi-instancia** — un solo target por proyecto (`.env`). Pendiente: perfiles
  de instancia con nombre (`dev`, `staging`).
- **Campos del panel que son informativos** — `autonomy` y
  `securityInterviewRequired` se guardan y se reportan, pero el pipeline lee la
  decisión de delegación desde `.sdd/setup-state.json` (se define con `odoo_setup
  mode=autonomy`) y exige la entrevista de seguridad a través de la compuerta de
  contenido de ARCHITECTURE. Anotado como trabajo pendiente.
- **La captura de manuales necesita un navegador y una contraseña** —
  `playwright-core` es una dependencia opcional y el navegador en sí no se
  distribuye: sin Chromium, Chrome o Edge la captura responde `NEEDS_BROWSER` con
  la línea de instalación. Una sesión web también necesita la **contraseña** de la
  cuenta (Odoo rechaza una API key en los logins interactivos), así que un
  proyecto que solo tiene API key puede escribir y lintear un manual, pero no
  manejar las capturas automáticamente.
- **El manual cubre un modelo por vez** — el canon se lee por modelo, así que un
  recorrido entre modelos son varios flujos planificados contra varios cánones, y
  el storyboard conserva la huella del canon con el que se planificó
  (`CANON-STALE` avisa cuando la instancia se movió).
- **Las familias de UI 15, 16, 18 y 19 están declaradas, no probadas** — los
  selectores de captura están verificados de punta a punta en 17; las otras
  familias comparten la misma estructura pero hay que contrastarlas contra una
  instancia de desarrollo antes de confiar en ellas, y `familyFor` rechaza un
  major que no conoce en vez de adivinar.
- **El lint de prosa es advisory** — las reglas de tics de IA son heurísticas y
  cada una es un WARN por diseño en la v1: solo pueden bloquear las reglas
  estructurales (una frase por paso capturado, sin encabezado dentro de un
  procedimiento) y las reglas del canon.
- **Sin renderer rico** — la salida de las tools es texto en la GUI web de DSH.

---

## ❓ Problemas frecuentes

| Síntoma | Qué significa | Qué hacer |
|---|---|---|
| `NOT CONFIGURED` | no hay `.env` usable en la cascada | `odoo_setup mode=interactive` |
| `NEEDS_SECRET` | el scaffold existe pero `ODOO_PASSWORD` está vacío | completalo en el archivo, nunca en el chat |
| `NOT AUTHORIZED` | hay credenciales, pero ningún grant humano vigente para ese destino | `odoo_setup mode=authorize` |
| `Instance unreachable` | falló la sonda de versión | revisá URL/puerto y que la instancia esté corriendo |
| Las mutaciones siempre se deniegan | no hay checkpoint, o la spec todavía no está en `WRITE_CODE` | aprobá las compuertas y después `sdd_checkpoint create` |
| Todo está detenido | existe `stop.md` | leelo y después borralo |
| Un cambio del panel parece ignorado | el `.sdd/config.json` del proyecto manda sobre la capa global de Ajustes | `odoo_config mode=read` muestra los valores efectivos |
| Un directorio de spec "no aparece" | estás en otra carpeta de proyecto | abrí la carpeta de ese proyecto en una sesión |
| `NEEDS_BROWSER` de `odoo_manual capture` | no se encontró Chromium/Chrome/Edge | `npx playwright-core install chromium`, o pasá `browser: "chrome"` |
| `NEEDS_TTS` de `odoo_video voice` | no hay `ttsCommand` configurado (o la plantilla usa `{voice}` y no hay voz) | configura `ttsCommand` (el mensaje trae voces ejemplo por idioma: piper-tts offline, edge-tts nube) y `ttsVoice` |
| `NEEDS_FFMPEG` de `odoo_video assemble` | falta el `ffmpeg` del sistema (config `ffmpegPath`) o no corre | instala ffmpeg 4.x+ (7.x verificado) o apunta `ffmpegPath` a su ruta absoluta |
| `NEEDS_VIDEO_CAPTURE` de `odoo_video assemble` | no hay `capture.json` con entrada de video para ese flujo (la captura corrió sin grabar, es otro flujo, o nunca corrió) | corre primero `odoo_video operation=capture flow=<id>` — el ensamblado siempre lee el webm grabado |
| `no view rendered within 30s` de `odoo_video capture` | la URL no es una acción del webclient, o la instancia no es alcanzable desde el navegador | abre la acción una vez con `odoo_manual operation=explore` o corrige el xmlid del target; la captura espera la vista Y sus registros, nunca solo `load` |
| Un vídeo que empieza en pantalla en blanco | reproducción de una captura anterior a esta comprobación, o un webm cortado a mano | vuelve a capturar: el ensamblado conserva solo el final de la ventana de cada `goto` y registra el adelanto como `captureOffsetSeconds` en `video/video.json` |
| la captura de video pide el ffmpeg de Playwright | la grabación usa el ffmpeg EMPAQUETADO de Playwright, una descarga separada del chromium | `npx playwright install ffmpeg` (o `npx playwright-core install ffmpeg`) una sola vez |
| `NEEDS_WEB_SESSION` de `odoo_manual capture` | el destino solo tiene una API key, y Odoo la rechaza en los logins interactivos | poné una contraseña de cuenta en `.env`, o creá `.sdd/session.json` con `odoo_session` |
| La pantalla capturada está en el idioma equivocado | el navegador sigue el idioma del usuario que hace login, y el plugin nunca escribe en `res.users` | capturá con un usuario cuyo idioma coincida con el del manual |
| Se rechaza `allow_actions=true` | el guard lo clasifica como mutación | pasá la spec a una fase de escritura, o registrá un waiver — y usá una base desechable |
| Una captura se detiene a mitad | el primer paso que falla aborta la corrida y escribe lo que alcanzó | corregí el storyboard y volvé a correr con `from_step: "<el id del paso que falla>"` |
| `odoo_docs` dice `NEEDS_CONTENT` sin que falte ningún fragmento | un ERROR de manual (una captura sin su imagen, un canon obsoleto) o un scaffold que quedó en su lugar | leé la sección `## User manual` del reporte |
| `dsh: pnpm was not found` al instalar | `dsh plugin` delega en pnpm, que no viene con Node | `npm i -g pnpm` (Node 25 retiró `corepack`) y reintentá `dsh plugin add` |
| `Unsupported engine` o `plugin … is not compatible` | el host aplica solo `engines.dsh`: un DSH anterior a **0.2.2-rc.1** se rechaza; pnpm además aplica el `node >= 24.9.0` de ese host | actualizá DSH (y Node): el plugin nunca corre sobre un host contra el que no se probó |
| `ERR_MODULE_NOT_FOUND … scripts/prepare.mjs` durante `npm rebuild` | un tarball empaquetado antes de 0.19.0 no incluía el hook de build, y `npm rebuild` ejecuta `prepare` | reinstalá el paquete (el hook ahora viaja y es no-op cuando `lib/` ya está compilado) |

---

## 🧩 Detalles de implementación

<details>
<summary>Forma del plugin, mapa de fuentes y decisiones de seguridad — clic para expandir</summary>

### Forma del plugin

Sigue la convención de tool-plugins de DSH (`dsh-tool-todo`, `dsh-tool-goal`):
exports nombrados `name`, `inject`, `Config` (esquema schemastery) y
`apply(ctx, config)`, registrando cada tool con `defineTool` de
`@deepseek-ai/dsh-tools`. La mitad de navegador es un bundle JS plano del
ModuleLoader que aporta la sección **Odoo SDD** en Ajustes.

### Mapa de fuentes

| Archivo | Rol |
|---|---|
| `src/index.ts` | Entrada del plugin: registro de las 19 tools, resolución de configuración y el guard de política |
| `src/types.ts` | Tipos públicos de payload (nunca contienen material secreto) |
| `src/credentials.ts` | Cascada de credenciales, carga/validación del `.env`, verificación de permisos, `redact()`, fail-closed |
| `src/odoo-client.ts` | Cliente JSON-RPC: `common.version`, `authenticate`, `execute_kw`, `button_immediate_*`, `ir.logging`, `/web/session/authenticate` |
| `src/test-scan.ts` | Reglas estáticas para las pruebas Python del módulo (test no importado, clase de caso, densidad de aserciones, errores tragados, `cr.commit`, sleeps, skips, `SavepointCase` por versión) |
| `src/tests-tool.ts` | La tool `odoo_tests` (`check`/`plan`/`scaffold`), usable sin el pipeline |
| `src/tools-runtime.ts` | Cuerpos de tools contra Odoo: `odoo_execute` (allowlist + captura de pre-imagen), `odoo_validate`, `odoo_module`, `odoo_errors` |
| `src/sdd-state.ts` | Máquina de fases, compuertas, KB append-only, veredictos, compuerta de contenido de seguridad, `stop.md` |
| `src/spec-reqs.ts` | Ids estables `REQ-<AREA>-NN`, fingerprints por requisito, `req-baseline.json` y detección de DRIFT |
| `src/coverage.ts` | Cobertura bidireccional spec↔módulo: anotaciones en código vs requisitos declarados, huérfanos, pisos por modalidad, escalera de estados |
| `src/ambiguity-lint.ts` | Lint advisory de redacción para `spec.md` (adjetivos sin cuantificar, placeholders, pasivas, compuestos) |
| `src/checkpoints.ts` | Store de checkpoints: manifest, snapshot/restore de archivos, journal de datos, presupuesto de purga |
| `src/security-scan.ts` | Reglas de seguridad estáticas sin instancia (`scanModule`) con hallazgos `file:line` |
| `src/audit.ts` | Log de auditoría append-only sanitizado (`.sdd/audit.jsonl`) y el wrapper `withAudit` |
| `src/setup-state.ts` | Persistencia de la decisión de onboarding + modo de delegación (`.sdd/setup-state.json`) |
| `src/grants.ts` | Recibos de autorización humana (`.sdd/grants.json`), atados a fingerprint y fail-closed |
| `src/atomic.ts` | Escrituras atómicas (reintento acotado en `EPERM`/`EACCES`/`EBUSY`) y cuarentena de corrupción + reporte de recuperación |
| `src/paths.ts` | Resolución multiplataforma de `module_dir` |
| `src/specs-location.ts` | Dónde viven los specs: disposición project vs central, resolución de la raíz de sesión con procedencia, slug/marcador/colisiones |
| `src/lifecycle.ts` | Inventario de propiedad y la primitiva `purge` (solo estado propio; nunca `.env`/`stop.md`/`specs/`) |
| `src/docs-scan.ts` | Reglas de documentación: fragmentos OCA + Diátaxis, esquema de versión, changelog, index.html, docstrings, xpath, OWL |
| `src/docs-tool.ts` | La tool `odoo_docs` (check/plan/scaffold/report), usable sin el pipeline |
| `src/project-conventions.ts` | Resuelve el idioma de la documentación desde las reglas del propio proyecto, con inglés por defecto |
| `src/manual-model.ts` | Resolución del espacio de trabajo de manuales (3 modos, con verificación de contención), lectura/escritura del storyboard con ids estables, plan desde el canon, validación de las reglas del canon, clases de seguridad de los pasos |
| `src/manual-canon.ts` | El canon funcional: tokenizador del arch XML (17 expresiones `invisible=`, `attrs`/`states` de 14–16, estados del statusbar), escaneo de errores del módulo, lista de lectura por versión (`load_views` ≤15, `get_views` 16+, `groups_id`→`group_ids` en 19) y una huella estable |
| `src/manual-tool.ts` | La tool `odoo_manual`: canon / plan / lint / render / explore / capture / import, con la política create-only y la ruta de reanudación tras un fallo |
| `src/video-model.ts` | Resolución del workspace de video (`videosDir`, con verificación de contención), la validación ligera del storyboard que el video necesita (canon obsoleto, flujos vacíos, targets desconocidos, pasos mudos) y el import `from_manual` (copia canon + storyboard, jamás toca el manual) |
| `src/video-voice.ts` | Narración: split de la plantilla con comillas, spawn del TTS sin shell, `.wav` por paso idempotentes, `voice.json` con duraciones probadas, voces ejemplo por idioma para el remedio `NEEDS_TTS` |
| `src/video-assemble.ts` | Ensamblado con ffmpeg: corte por paso + congelado de frame (tpad) a la duración de la narración, concat demuxer, mux faststart, SRT/VTT siempre, subtítulos quemados opcionales, reporte `video.json` |
| `src/video-tool.ts` | La tool `odoo_video`: plan (instancia o `from_manual`) / voice / capture (grabación siempre activa, mismas compuertas que el manual) / assemble |
| `src/prose-lint.ts` | Reglas de prosa advisory en español e inglés (patrones que delatan IA, longitud de frase, legibilidad) más las dos reglas estructurales ERROR de las que depende el formato OCA |
| `src/manual-render.ts` | Render determinista de Markdown + página de Apps, `sha256` por archivo, y un archivo editado a mano que nunca se sobrescribe salvo `overwrite=true` |
| `src/manual-capture.ts` | El motor de navegador: bloqueo de origen, gating de pasos con solo-lectura primero, PNG anotados (1440×900, overlay removido), snapshots ARIA, `capture.json` |
| `src/manual-import.ts` | Lector del dialecto `codegen` de Playwright: mapeo de etiquetas al canon, pasos genéricos con WARN en vez de adivinanzas, rechazo de credenciales por línea |
| `src/manual-audit.ts` | Auditoría de solo lectura que permite al veredicto de documentación cubrir un manual existente (cobertura de capturas, obsolescencia del canon, salud de la prosa) y no cambia nada cuando no hay ninguno |

### Decisiones de seguridad

- El secreto solo existe dentro de `credentials.ts` y en los parámetros RPC; toda
  salida pasa por `redact()` (incluidas las formas de URL `user:pass@`).
- El guard de transporte rechaza todo lo que no sea HTTPS o loopback antes de
  autenticar.
- Las mutaciones son fail-closed por partida doble: la allowlist se lee en vivo en
  cada llamada, y el guard de política deniega `create`/`write`/`unlink` salvo que
  exista un checkpoint y la spec esté en `WRITE_CODE` o más adelante.
- Los secretos nunca se aceptan como parámetros de tool, nunca se escriben en el
  log de auditoría y nunca se piden por chat.
- Estado corrupto ⇒ reinicio (el progreso nunca se falsea); compuerta ambigua ⇒
  rechazo; verificación ausente ⇒ `DONE` inalcanzable; hueco de seguridad ⇒
  compuerta de ARCHITECTURE rechazada.

### Build y tests

```bash
npm run typecheck   # tsc --noEmit
npm run build       # genera lib/ (obligatorio: el main es lib/index.js)
npm test            # invariantes del servidor + contrato de host + bundle cliente + README + funcional/import
npm run test:package  # contenido del tarball (un archivo que el runtime carga y `files` omite)
```

Son exactamente los pasos que corre el [CI](.github/workflows/ci.yml) en Linux y
Windows, así que `npm run typecheck && npm test` en local reproduce el pipeline.

`test:package` además **simula la publicación**: copia el paquete sin `lib/` (lo
que tiene un clon recién hecho), corre `npm pack` sobre esa copia y verifica que el
tarball igual contenga el entry point compilado. Ese es el chequeo que evita que
una versión publicada sea instalable pero no cargable. Después simula el lado de
la **instalación** — un árbol con el compilador pero sin los peers opcionales del
host, que es exactamente el `npm install` del CI — y verifica que ahí `prepare`
salga 0 y `prepack` se niegue.

### Publicar (maintainers)

**npm no sigue a GitHub.** Son dos registros independientes: un push, un tag o un
Release actualizan GitHub y nada más, y `npm publish` actualiza npm y nada más.
Una versión publicada es **inmutable** — no se puede sobrescribir, solo superar
con una mayor — así que `package.json` se sube en cada release.

`lib/` es salida de build y está en `.gitignore`, así que el tarball lo arma el
hook `prepack` — nunca a mano, nunca desde un árbol viejo. La **primera**
publicación es manual (npm solo deja registrar un trusted publisher para un
paquete que ya existe):

```bash
npm login                 # una vez; después `npm whoami` debe responder
npm publish               # prepack corre `tsc` y empaqueta el resultado
npm view dsh-odoo-sdd version   # verificá qué tiene el registro realmente
```

Después de eso lo hace
[`.github/workflows/publish.yml`](.github/workflows/publish.yml): **publicar un
GitHub Release** (o un *workflow_dispatch* manual) corre las mismas compuertas que
el CI — typecheck, build, tests, la simulación de publicación —, verifica que el
tag del release coincida con `package.json`, rechaza una versión que ya está en el
registro, y publica con **provenance** vía **trusted publishing** OIDC de npm, así
que no hay ningún `NPM_TOKEN` en este repositorio.

Configuración única para esa automatización: en npmjs.com → el paquete →
*Settings* → *Trusted publishers* → *Add* → provider **GitHub Actions**, owner
`fhidalgodev`, repositorio `dsh-odoo-sdd`, nombre de archivo del workflow
`publish.yml`, environment **vacío** (si no coincide, es un
`403 npm-trusted-publisher-not-configured`). ¿Preferís un token? Creá un granular
access token con *bypass 2FA* y guardalo como el secreto `NPM_TOKEN` — el workflow
dice dónde.

Así, un release es: subir la versión → mergear → publicar el Release, y el tag, el
Release y la versión de npm terminan coincidiendo.

Los mismos hooks hacen que instalar desde el repositorio funcione: `prepare`
compila las fuentes cuando TypeScript está presente
(`npm i github:fhidalgodev/dsh-odoo-sdd`) y se salta con un aviso cuando no lo
está (una instalación `file:` no tiene devDependencies).

</details>

---

## ⭐ Star History

<a href="https://www.star-history.com/?repos=fhidalgodev%2Fdsh-odoo-sdd&type=date&legend=top-left">
  <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=fhidalgodev/dsh-odoo-sdd&type=date&legend=top-left" />
</a>

> Gráfico generado en vivo por la API de [star-history.com](https://star-history.com).

## 🙏 Agradecimientos

Construido para la comunidad de desarrolladores de Odoo, este plugin descansa
sobre tres ecosistemas:

- **[Odoo Community Association (OCA)](https://github.com/OCA)** — las
  convenciones de código, la estructura de módulos y las compuertas de calidad que
  este pipeline aplica.
- **[DeepSeek Harness (DSH)](https://github.com/deepseek-ai)** — la arquitectura
  de plugins (tools/plugins Cordis, skills, subagentes) sobre la que corre.
- **[Playwright](https://playwright.dev)** — el motor de navegador detrás de
  `odoo_manual operation=capture`, y las grabaciones `codegen` que lee el import.

La guía de escritura de manuales se apoya en la práctica publicada de
documentación en vez de inventar un estilo propio: **Diátaxis** (los cuadrantes de
fragmentos que el readme OCA ya sigue), **Information Mapping** y el
**minimalismo de John Carroll** para procedimientos orientados a tareas,
**ISO/IEC 26514** para la estructura de la documentación de usuario, y la escuela
**StoryBrand / "lead with insight"** de encuadre narrativo para el acto de
apertura. La lista anti-IA de prosa se nutrió de colecciones públicas de
storytelling y de skills de contenido (en particular
[artemnovitckii/content-skills](https://github.com/artemnovitckii/content-skills),
[yaxeen/storytelling-skills](https://github.com/yaxeen/storytelling-skills),
[Donchitos/team-narrative](https://github.com/Donchitos/team-narrative),
[fabric](https://github.com/danielmiessler/fabric) y
[lyndonkl](https://github.com/lyndonkl/claude)) — las reglas que sobrevivieron son
las que atrapan un tic real sin castigar una frase clara.

Gracias a todos los que aportan patrones, revisiones e ideas que dan forma al
flujo SDD. Contribuidores:

<p align="center">
  <a href="https://github.com/fhidalgodev/dsh-odoo-sdd/graphs/contributors">
    <img src="https://contrib.rocks/image?repo=fhidalgodev/dsh-odoo-sdd&max=100&columns=12" alt="Contributors to fhidalgodev/dsh-odoo-sdd" width="480"/>
  </a>
</p>

---

## 📜 Licencia

MIT © [Franyer Hidalgo](https://github.com/fhidalgodev)
