<p align="center">
  <img src="assets/odoo-sdd.svg" alt="dsh-odoo-sdd — Spec-Driven Development for Odoo" width="800" />
</p>

# Spec-Driven Development for Odoo

<div align="center">

<h3>Turn DeepSeek Harness into a closed-loop Odoo workshop:<br/>spec → architecture → code → verify, against a real instance</h3>

<!-- The npm badges resolve against the published package. Update the owner/repo
     in the GitHub badges if this is ever forked elsewhere. -->
<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>Author:</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>If this plugin saves you time, a star helps a lot</strong> — it is the signal that keeps the pipeline maintained.
      <br><br>
      🐛 <strong>Found a bug or want a feature?</strong> Open an issue in any language. Reproducible reports and honest "this did not work" notes are the most useful thing you can send.
    </td>
  </tr>
</table>

</div>

---

## ⚡ Summary

`dsh-odoo-sdd` turns **DeepSeek Harness** into a specification-driven (SDD)
Odoo development pipeline. Two ideas hold it together:

- **Closed feedback loop** — the agent installs and upgrades modules, reads the
  server traceback and retries against a **real, running** Odoo instance. The
  plugin never starts Docker or `odoo-bin`: you point it at an instance you
  already have (dev/staging) through a gitignored `.env`, and the tools speak
  plain JSON-RPC.
- **Pipeline safety** — every phase persists to disk, the gates are fail-closed
  on an explicit `APPROVED` marker, three consecutive failures force a
  root-cause diagnosis, verify/fix iterations are capped, `stop.md` halts
  everything, and verdicts are honest: a failed verification is persisted as
  FAILED and can never be reported as success.
- **No change without a spec** — a request that arrives *after* the pipeline
  closed ("now also change X" in the same chat) is a new change, so it gets its
  own (small) spec or an explicit waiver from you. The guard covers instance
  mutations and source edits alike, and says which of the three ways out is
  available instead of quietly editing.

> [!NOTE]
> **What it is not:** an infrastructure orchestrator, a credential manager over
> chat, or an auto-committer. It writes no commit and never asks you for a
> password.

### Requirements

| Need | Why |
|---|---|
| **DSH ≥ 0.2.2-rc.1** on **Node ≥ 20** | the plugin mounts as a Cordis bundle and uses the `tools` service; that host declares **Node ≥ 24.9.0** itself, so Node 24 is the practical floor for running it (the plugin's own code runs on 20) |
| An **existing Odoo instance** reachable over HTTP(S) | the loop needs a real server to install into and read tracebacks from |
| A **disposable dev/staging database** | verification installs modules and writes test data |
| *(optional)* a **Playwright** browser tool | only for the UI layer of verification and for narrated-manual captures; without it those scenarios are marked for manual checking |
| *(optional)* `playwright-core` **1.63.0** (pinned) | ships with the plugin as an optional dependency; the capture motor drives Chromium, Chrome or Edge, and answers `NEEDS_BROWSER` with the one-line remedy when none is present |

---

## 🔭 How it works

```mermaid
flowchart TD
    A(["Idea or request"]) --> C1["1 CLARIFY<br/>interview and security questions"]
    C1 --> R2["2 READ_SPEC<br/>immutable spec.md"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>models, views, security, test plan"]
    A3 -->|APPROVED| W4["4 WRITE_CODE<br/>module source and OCA docs"]
    W4 --> V5["5 VERIFY<br/>static, install, RPC, UI"]
    V5 -->|PASSED| D9(["handoff.md - DONE"])
    V5 -->|FAILED| F6["FIX_LOOP<br/>root cause, max 5 iterations"]
    F6 --> V5
    W4 -.-> M7["odoo_module install or upgrade"]
    M7 -.->|traceback| E8["odoo_errors"]
    E8 -.-> F6
```

The specification is the single source of truth and it is **immutable**: the code
adapts to the spec, never the other way around. Gates are human-answered by
default, and can be delegated to a human-proxy agent when you choose the
autonomous mode.

---

## ✨ Key features

- 🎯 **Spec before code, always.** `spec.md` carries numbered acceptance criteria;
  `DONE` is unreachable without a persisted `PASSED` verdict on disk.
- 🧬 **Stable requirement identity.** Every requirement block gets a stable
  `REQ-<AREA>-NN` id (section slug + ordinal) plus a fingerprint committed to
  `req-baseline.json`. Rewording a requirement under an existing id is **DRIFT**:
  the advance is refused until you either revert it or accept it explicitly
  (`sdd_phase operation=baseline`), so a plan or a test never silently refers to
  a requirement that no longer says what it said.
- 🔗 **Bidirectional coverage.** Annotate code and tests with `# REQ-<AREA>-NN`
  and `odoo_validate module_dir=… spec_dir=…` checks both directions —
  requirements with no implementation, and orphaned annotations whose id the
  spec no longer declares. Floors: `must`/`shall` block at 1.0, `should` is
  reported at 0.8, `may` is informational. Each requirement lands on the ladder
  `covered / untested / test-only / unimplemented`.
- 🔍 **Ambiguity lint (advisory).** `mark_spec_loaded` reports unquantified
  adjectives, vague quantifiers, `TBD` placeholders, passives with no actor and
  and/or compounds, each with a concrete rewrite — advice, never a gate.
- 🧪 **Tests that can actually fail.** `odoo_tests` checks the suite an install
  log cannot see: a test file Odoo never imports (nothing loads it), a test that
  asserts nothing, a swallowed exception, `cr.commit()` breaking the per-test
  rollback, `time.sleep`, skips — and maps every acceptance criterion to the test
  that mentions it. `scaffold` writes RED stubs (they raise until implemented)
  instead of vacuous passes.
- 🔴 **RED before GREEN on a bug fix.** In `mode=bug` the pipeline refuses to
  leave `WRITE_CODE` until `test-plan.md` records the reproduction that FAILED
  first (`red (evidence: …)`, or `pass (red first: …)` once fixed) — a regression
  test that never failed proves nothing.
- 🔁 **A real feedback loop.** `odoo_module install` returns the server's own
  output or traceback; `odoo_errors` reads `ir.logging`; failures become a
  persisted FAILED verdict instead of a hopeful summary.
- 🧾 **Not only modules.** The same machine also runs a **functional** spec
  (`mode=functional`): configure a live instance and load data in
  human-approved batches, with Odoo's own importer for CSV/Excel, and close with
  a runbook a person can repeat. → [The functional path](#-the-functional-path-configure-and-import)
- 🔒 **Credentials are not consent.** The first socket of a project needs an
  explicit human authorization bound to `url + db + user` (`.sdd/grants.json`).
- ⏪ **Rollback that tells the truth.** Checkpoints snapshot files and journal
  every `odoo_execute` pre-image, and a restore always reports the files created
  after the checkpoint. What it cannot undo, it says so.
- 🧱 **Documentation as a gate.** `odoo_docs` produces the OCA `readme/`
  fragments, the Apps `index.html` and a mandatory changelog entry — and it
  works on an existing module with no spec, no phase and no instance.
- 🧪 **Instance-free static layers.** `odoo_validate` (structure + ACL coherence)
  and `odoo_security_scan` (raw SQL, `sudo()`, `auth="none"`, QWeb `t-raw`…)
  give findings with `file:line` before anything is installed.
- 📖 **Manuals written like a story, checked like code.** `odoo_manual` reads
  the functional canon of a model from the instance (buttons and the states that
  show them, required fields, notebook pages, menus, access rules, the exact
  `raise UserError` sites), plans a storyboard, lints the prose against the
  canon, captures annotated screenshots **read-only by default**, and renders a
  manual a person can follow — a labelled screenshot per instruction, with the
  recovery path when the canon says the button can raise.
- 🎬 **Narrated video, same evidence.** `odoo_video` turns the same storyboard
  into a narrated screencast: each step's procedure sentence becomes the voice
  line (spoken by a TTS command **you** configure — offline piper-tts or cloud
  edge-tts) and a caption, `capture` records the browser session with per-step
  timestamps, and `assemble` cuts, freeze-frames and muxes `video/<flow>.mp4`
  with ffmpeg. SRT/VTT captions always ship (the export path to any video
  platform); the raw webm is kept so re-voicing never needs a re-capture.
- 🧑⚖️ **Policy the model cannot relax.** Allowlist, guards and delegation mode
  all require native human approval to change.
- 🤖 **Supervised or autonomous.** The same pipeline runs with a human answering
  each gate, or with a human-proxy agent and unattended goal rounds until `DONE`
  or `BLOCKED`.
- 📁 **Specs where you want them.** Keep them beside each project, or collect
  every project's specs in one folder you can search.
- 🖥️ **Linux, macOS and Windows.** Paths, atomic writes and `.env` permissions
  are handled per platform and tested on Windows in CI.

---

## 🚀 Quick start

### 1. Install into a profile

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

That installs the **published package from the npm registry** — no clone, no
build, nothing to compile on your side. `dsh plugin` is a thin `pnpm` forwarder:
it runs `pnpm add` inside the profile directory and then registers the bundle
(`dsh.profile.bundles`). Two consequences worth knowing:

- **pnpm must be on your `PATH`** (`dsh plugin` reports it when it is not).
- Any pnpm spec works, so you can pin a version:
  `dsh plugin --profile web add dsh-odoo-sdd@0.20.0`.

Prefer plain npm — a project that depends on the plugin, or a CI job?

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

> [!IMPORTANT]
> Restart DSH and refresh the browser tab after installing. Client-side changes
> (the **Odoo SDD** settings panel) load from the installed package.

**Working on the plugin itself?** Install the checkout instead. `lib/` is build
output and is **not** committed; `npm install` builds it through the `prepare`
hook, and you can always ask for it explicitly:

```bash
git clone https://github.com/fhidalgodev/dsh-odoo-sdd && cd dsh-odoo-sdd
npm install          # devDependencies: typescript, then prepare builds lib/
npm run host:deps    # optional peers, needed to compile (no-save)
npm run build        # emits lib/ — required, package main is lib/index.js
dsh plugin --profile odoo add .
```

> [!NOTE]
> A **git** install runs that `prepare` build on the consumer's machine, and pnpm
> blocks dependency build scripts until they are allowed: the command tells you
> the exact key to add under `allowBuilds` in the profile's
> `pnpm-workspace.yaml`. A registry install needs none of that — the tarball
> already ships `lib/`.

`dsh plugin add` records the bundle in the profile's `package.json`
(`dsh.profile.bundles`), and the package ships a Cordis patch
(`cordis.patch.yml`) that inserts its own row — so there is no manual
composition step. `dsh --profile <name> --dump-config` prints the composed tree
without booting anything.

### 2. Give it credentials (once per project)

Ask the agent to run `odoo_setup mode=check`. It writes a **secret-free**
scaffold and you fill the password yourself:

```text
odoo_setup mode=interactive url=http://localhost:8069 db=odoo_dev username=admin
# then fill ODOO_PASSWORD (an Odoo API key is recommended) in the printed file
odoo_setup mode=authorize   # asks YOU, once, to authorize that exact target
```

Or do it by hand — the plugin looks for the first of these that exists:

| # | Location | Scope |
|---|---|---|
| 1 | `ODOO_SDD_ENV_FILE` | explicit environment override |
| 2 | `<project>/.sdd/instances/<name>.env` | **one target per file**, the active instance |
| 3 | `<project>/.sdd/.env` | single-target layout (also listed as the instance `default`) |
| 4 | `~/.config/dsh-odoo-sdd/.env` (honors `$XDG_CONFIG_HOME`) | user scope — one set of dev credentials for every project |
| 5 | `<project>/.env` | legacy location, still supported (reported as legacy) |

**Several targets in one project.** A workspace that needs a local CE server, a
staging EE server and a client's instance no longer overwrites its only `.env`:
each target gets its own file under `.sdd/instances/`, and one is active.

```bash
odoo_setup mode=instance instance=list                      # what exists, non-secret identity
odoo_setup mode=instance instance=add name=staging url=https://stg.example.com db=stg username=ci
# fill the secret in .sdd/instances/staging.env by hand, then:
odoo_setup mode=authorize name=staging                     # grants THAT target
odoo_setup mode=instance instance=use name=staging          # activate it FOR THIS SESSION
odoo_setup mode=instance instance=remove name=staging       # needs confirm_destructive=true
odoo_setup mode=revoke name=staging                         # drops only that target's grant
```

**The active target belongs to the SESSION, not the project.** Each chat keeps its
own pointer (`.sdd/instances/active-<sessionId>.json`), so two sessions on one
project can work in two environments at once — one on staging, another on a
client's server — without either moving the other.

**A session that has not chosen is ASKED, never given a default.** The first Odoo
operation in a new chat refuses and returns the question, with the available
targets and their non-secret identity:

```
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=…`, …
```

Inheriting the project's last-used target is convenient exactly once and dangerous
afterwards: another session switching environments would silently redirect a
working one, and its tool answers would still just say `OK`. One question per
session buys that away. `.sdd/instances/active.json` is kept only as the
*suggestion* shown in the question. A project with no named instances (just
`.sdd/.env`, the single-target layout) is never asked, and
`requireInstanceChoice: false` restores the old inheritance for anyone who
prefers it.

Authorization is **per target** (`url`+`db`+`user`), so two instances on the same
server are two grants. **`authorize` takes the target's NAME**, which is what makes
switching recoverable: grants expire (12 h), and requiring activation before
authorization used to be a closed circle — `use` demands a live grant for the
target, while `authorize` only ever saw the active one. Naming it breaks the
circle: authorize `staging` without leaving `main`.

Activating a target that has no live grant is still refused — the pointer decides
where mutations go, so it cannot be switched to something nobody approved. With
**several** instances and **none** active, every connection tool refuses instead
of picking one by order, and an old `.sdd/.env` keeps working untouched as
`default` until you choose to move it yourself.

```bash
mkdir -p ~/.config/dsh-odoo-sdd && cd ~/.config/dsh-odoo-sdd
cp <plugin>/.env.example .env && chmod 600 .env
# fill: ODOO_URL, ODOO_DB, ODOO_USERNAME, and ONE secret:
#   ODOO_PASSWORD=<account password>   or   ODOO_API_KEY=<api key>
```

**The secret has two accepted names.** `ODOO_PASSWORD` and `ODOO_API_KEY` are both
read, and the API key is the recommended one: Odoo accepts it wherever it accepts
a password over RPC (Settings → Users → API Keys, and use a key with the global
scope). If both are set, `ODOO_PASSWORD` wins and the plugin **reports** that the
other was ignored, so a rotated credential cannot go unnoticed.

There is one place an API key does **not** work, and it is Odoo's rule, not this
plugin's: `odoo_session` and `odoo_import` authenticate a **web session**, and
Odoo authenticates interactive logins with `interactive: True` while only
consulting the API-key table otherwise. Configure an account password if you need
those two; everything else works with the key.

### Which API the plugin speaks

| Server | Transport | Credential |
|---|---|---|
| **Odoo 19+** | `POST /json/2/<model>/<method>` — named parameters, `Authorization: Bearer` | API key |
| Any version | `POST /jsonrpc` — `execute_kw` | password **or** API key |

On 19+ the plugin uses the modern JSON-2 API: no `uid` round-trip, named
parameters validated by Odoo itself, read-replica routing for `@api.readonly`
methods, and errors that arrive as an HTTP status with a structured body. It falls
back to classic JSON-RPC automatically in three cases: the route is missing, the server
predates it, or **the credential cannot be used there at all**. That last one matters:
JSON-2 authenticates with `Authorization: Bearer` only, so a target configured with an
account PASSWORD answers `401` on every call — and a `401` means the request never ran, so
serving it over the other transport cannot double-apply anything. A call the server actually
PROCESSED and refused (`403`, `422`, a domain error) is **never** re-sent. Pin
the choice with `odooApi: auto | json2 | jsonrpc` in the panel or with
`odoo_config`; `json2` reports a missing route instead of degrading quietly.

Business actions (`action_*`, `button_*`, `do_*`) always use `execute_kw`: JSON-2
has no positional-argument form, and inventing a position→name mapping for a
method whose signature the plugin does not know is exactly the kind of silent
mis-send it refuses to make.

> [!WARNING]
> Never paste a password into the chat, a spec, a commit or an issue. The plugin
> refuses a group/world-readable `.env`, redacts the secret from every tool
> output, and stores session cookies in `.sdd/session.json` (mode 600) without
> ever returning them to the model.

### 3. Ask for the thing you want

```text
Implement a sale order approval module for Odoo 19, using the SDD workflow.
```

The agent picks up `odoo-sdd-workflow` from the session skill catalog and follows
the protocol. If you want to be explicit — or you want to be sure the full
instructions are loaded — start your message with `/odoo-sdd-workflow`.

For configuration and data work on a running instance, ask for that instead:

```text
Set up company, taxes and chart of accounts in my dev instance, then import
this customers.csv — functional SDD, dev environment, no production.
```

That selects `odoo-functional-sdd` (or `/odoo-functional-sdd` explicitly) and
the `functional` spec mode described below.

---

## 🧭 The five phases

| Phase | What happens | Gate to leave it |
|---|---|---|
| **CLARIFY** | Intent recorded (`mode` create/bug, `licensed`, and optionally `translations` — see below) and the security interview answered: groups, ACLs, record rules, `sudo()` justification, public routes | `sdd_phase clarify` |
| **READ_SPEC** | `spec.md` is assimilated: business context, numbered acceptance criteria, constraints, target Odoo version. **Writing code here is forbidden.** | `APPROVED` + `mark_spec_loaded` |
| **ARCHITECTURE** | Models, views (including extra view types and a search view where they matter), reports, security matrix and `test-plan.md` | `APPROVED` |
| **WRITE_CODE** | The module is implemented with version-pinned Odoo patterns and its OCA documentation. In `mode=bug` the failing reproduction comes FIRST and is recorded in `test-plan.md` as `red (evidence: …)` | static gates green |
| **VERIFY** | Ascending pyramid: static → install/upgrade → RPC/data → UI (Playwright) only for critical flows | persisted `PASSED` verdict |
| **FIX_LOOP** | Root-cause fixes. 3 consecutive failures force a consultant diagnosis; 5 iterations force `BLOCKED` | honest verdict |

### Are the strings translated? (`translations`)

CLARIFY asks it, next to the licensing question, and the answer is recorded **on the
spec** — not as a global setting, exactly like the edition. One workspace can hold a
client project that ships in Spanish and an internal fix that ships no languages at all.

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

- **Omitting `translations` means the work is not translated.** That is a normal
  answer, so unlike `mode` and `licensed` it is NOT a fail-closed gate.
- The codes are **gettext** (`ll` or `ll_CC`: `es_VE`, `es_PA`, `pt_BR`, `fr`). A
  malformed one is refused while it can still be corrected, instead of becoming a
  file the server would never load.
- Declaring languages makes `odoo_i18n` part of the plan and puts the matching
  acceptance criterion in `test-plan.md`: *"`i18n/<lang>.po` exists and no exported
  term is left with an empty `msgstr`"* — which `odoo_i18n operation=check` verifies.
- `sdd_phase status` reports them, so "did we translate this?" is answerable without
  opening `state.json`.

In ARCHITECTURE the agent also **asks** about the things that are cheap to decide
early and expensive to discover late: **extra view types** beyond form/tree
(including a **search view** for how a model is searched — custom filters,
favorites), **reports** (PDF via `ir.actions.report`/QWeb, SQL, CSV/XLSX, an
external tool), **web tours** (onboarding, test, or none — with the asset bundle
that loads them, because a tour no bundle loads never runs) and **demo data**
(which files, and what for). Each one is answered explicitly — "form + tree
only", "no reports needed", "no tours needed", "no demo data". Those are
**guide** decisions: recorded in `## Views` / `## Reports` / `## Tours` /
`## Demo data` and surfaced as warnings in `sdd_phase status`, non-blocking by
design — the security model is the only fail-closed content gate. The
version-by-version tour API, the `HttpCase` that executes a tour and the demo
traps live in `resources/references/tours-and-demo.md`; the test cycle, the case
classes per version and what makes a test trustworthy live in
`resources/references/odoo-tests.md`.

Per-spec artifacts (all on disk, resumable):

```text
specs/<NNN>-<slug>/
├── spec.md · architecture.md · test-plan.md
├── verify-verdict.txt   # honest persisted verdict
├── state.json           # phase, failures, iterations
├── kb.json              # decisions, discarded options, blockers, diagnoses
├── docs-report.md · security-report.md
└── handoff.md           # written by sdd_handoff when the run closes
```

---

## 🧩 The functional path (configure and import)

Not every Odoo job is code. Setting up a company, its taxes, its users and its
master data is **configuration and data**, and it happens on a live instance —
where a wrong click is not a failed test but a real record. The same SDD machine
covers it with a different middle phase and stricter closing rules.

```mermaid
graph TD
    C1["1 CLARIFY<br/>objective, instance, ENVIRONMENT"] --> R2["2 READ_SPEC<br/>spec.md, criteria, sources"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>to-be process, batches, risks"]
    A3 -->|APPROVED| X4["4 APPLY_CONFIG<br/>discovery + approved batches"]
    X4 --> V5["5 VERIFY<br/>re-read the records, evidence per AC"]
    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
```

| | Development run | Functional run |
|---|---|---|
| Chosen at `CLARIFY` | `mode=create` or `mode=bug` | `mode=functional` |
| Middle phase | `WRITE_CODE` (module source) | `APPLY_CONFIG` (batches against the instance) |
| Deliverable | module + OCA docs | configured instance + `functional-runbook.md` |
| Skill | `odoo-sdd-workflow` | `odoo-functional-sdd` |

**How a change reaches the instance.** Nothing is written "to see what happens":

1. **Discovery first**, under its OWN approval: which models, which fields, how
   many records. Reads are not mutations, but an approved scope is what stops
   "just looking around" from turning into a change.
2. **Plan**: the design becomes batches. Each one declares its destination and
   environment, the version and capabilities used, company and context, the
   acceptance criteria it covers, its ordered operations, record identity,
   preconditions, expected result, risks, recovery and manual steps.
3. **Approve**: the human sees the exact batch and approves it through the native
   approval seam. The receipt is bound to the hashes of the spec, the design, the
   plan and the batch — change any of them and the approval is void.
4. **Apply**: one operation at a time, re-checking those hashes, persisting each
   operation's state *before* the call and *after* the result.
5. **An unknown outcome is not a retry.** A timeout after a mutation may mean
   Odoo already committed, so the operation is marked `indeterminate`, the batch
   stops and the run parks until a human reconciles it.
6. **Close honestly**: `sdd_phase succeed` demands an explicit `pass` per
   acceptance criterion, the security review is mandatory, and so is the
   **runbook** (who does it, in which company, prerequisites, the verified menu
   path, the steps with their field labels, the expected result, how to check it
   and how to undo it) — whatever the documentation policy says.

**The environment is declared, never assumed.** `ODOO_SDD_ENVIRONMENT` on the
target says `dev`, `staging` or `production`. A plan that declares a different
environment than the target is refused (`environment-mismatch`), an undeclared
target asks for it (`NEEDS_ENVIRONMENT`), and production additionally needs a
declared backup reference plus its own approval. High-risk changes are proven in
staging first.

**Imports go through Odoo's importer, never through a hand-written parser:**

```text
odoo_import use=prepare file=... model=res.partner   # uploads, with its own approval
odoo_import use=preview   ...                        # what ODOO read: sheets, headers, sample
odoo_import use=map       ...                        # one decision per column, no blanks
odoo_import use=plan      ...                        # becomes an `apply` batch
odoo_functional operation=approve / apply            # the batch path, unchanged
```

The version contract is explicit (majors 10–19: `file`/`import_id` + JSONP on the
old endpoint, `ufile`/`id` + JSON on the new one, `do`/`execute_import` for the
apply), and a version outside the verified families is **refused** with what to
investigate instead of guessed. A file that changed after the upload invalidates
the mapping; a `nextrow` in the answer means the importer stopped mid-file and is
reported as partial, never as success — and the rows it counted are never
re-sent. The session cookie stays in `.sdd/session.json` (mode 600) and out of
every tool result.

> [!NOTE]
> The functional path needs an instance, and the importer needs a web session:
> run `odoo_session` once before `odoo_import use=prepare`.

---

## 🧰 The 19 tools

| Tool | Purpose |
|---|---|
| `odoo_connect` | Probe the instance: server version + authentication. Masked report; distinguishes `NEEDS_SETUP` / `NEEDS_SECRET` / `DEFERRED` / `SKIPPED` states (never asks for secrets in chat). |
| `odoo_setup` | Onboarding: `check` (cascade + gitignore + delegation mode), `interactive` (secret-free chmod-600 scaffold), **`authorize`** (ask the DEVELOPER, through native approval, for a connection grant bound to the current url/db/user), **`revoke`** (drop the grants), **`purge`** (plan first, then — with `confirm_destructive=true` plus human approval — remove only the plugin's own state under `.sdd/`), `later`, `skip`, `reset`, `autonomy` (supervised \| autonomous, human-approved). Secrets are never accepted as parameters. |
| `odoo_module` | `info` / `install` / `upgrade` on `ir.module.module` (`button_immediate_*`). Returns the server's own output or traceback, redacted — the closed feedback loop. |
| `odoo_execute` | Generic RPC (`execute_kw`) against the instance: **any public method of any model is callable**, and what it needs is decided by what it is. READS (`search`, `search_read`, `search_count`, `read`, `read_group`, `fields_get`, `name_get`, `name_search`, `default_get`, `exists`, `check_access_rights`, `check_access_rule`) run freely, with `fields`/`limit`/`order`/`offset` for projection and paging (a fractional or negative `offset` is refused, never clamped); CRUD mutations (`create`/`write`/`unlink`) require `confirm_destructive=true` AND the model in `executeAllowlist`, and are journaled so the data undo can replay them; every **business action** (`action_*`, `button_*`, `do_*`…, e.g. confirming an order) runs with `confirm_destructive=true` and **no allowlist**, because the plugin cannot replay its effect — it is never journaled, no undo exists for it, and a timeout is reported as an **INDETERMINATE** outcome instead of a plain failure — the same rule now applies to CRUD, because a `create` whose answer never arrived may already have committed and reporting it as a retryable failure is how one effect becomes two. Declare `precondition`/`postcondition` (the same shape a batch uses: `{domain, expect, count}`) to carry a state guard before the call and a state proof after it: if the precondition fails, nothing is sent; if the postcondition fails, the answer says the call WAS sent and the instance may be half-changed, and the CRUD mutation keeps its journal receipt (created id + pre-image) so it can still be compensated; without one, `OK` only means the server accepted the call. A **private method** (`_name`) is refused, and that wall is Odoo's own: `execute_kw` dispatches through `get_public_method`, which answers `AccessError` for `_…`, `init`, `@api.private` and the internal attribute names — so read the model in the source (the native/custom repositories are reported by `odoo_config mode=read`) and call the public button or action that wraps it (`sale.order._create_invoices` is private; the `sale.advance.payment.inv` wizard exposes the public `create_invoices()`). For several operations, or when a human approval per run is wanted, use a functional batch with `kind: "method"`. `context` is forwarded verbatim — use `allowed_company_ids`/`company_id` on multi-company instances — and the server still applies its own ACL. A large answer is BOUNDED and the cut is MARKED: the text says how many characters were dropped, that what you see is the beginning (not a complete JSON document) and how to page with `fields`/`limit`/`offset` — and for a mutation it explicitly forbids repeating the call to get more text. |
| `odoo_validate` | LOCAL, instance-free module structure check: `__manifest__.py` present + depends, declared data XML files exist (`views/*.xml` included), `security/ir.model.access.csv` when models are declared, and the OCA metadata the project's own `.pylintrc` requires (`manifest-required-keys`, `manifest-required-authors`, plus `license`/`version`, `website`, `README.rst`, `static/description/index.html`). View conventions: `position="replace"` without `priority`, `t-esc` (use `t-out`). Returns file:line findings plus the `module_dir` and project root it resolved (a relative path resolves against the session's folder, never the process cwd). |
| `odoo_errors` | Reads recent `ir.logging` server errors — the remote equivalent of fetching environment logs. |
| `odoo_session` | Mints a passwordless web session (the `connect_as_user` pattern) stored in `.sdd/session.json` (chmod 600) for Playwright UI tests. The cookie itself is never returned. |
| `sdd_phase` | The phase state machine: `init`, `status` (logbook summary, spec directory and specs location), `mark_spec_loaded`, `advance` (fail-closed gates: the host is asked for approval natively and the `approval_marker`/`approval_source` stay as the documented backstop for hosts with no approval seam), `fail` (failure ladder + FAILED verdict), `succeed` (PASSED verdict; refused outside VERIFY/FIX_LOOP, and unless every AC the SPEC declares has a row in `test-plan.md` reading an explicit `pass` — an omitted criterion is not a passing one, and the verdict records the fingerprint of the evidence it used so `DONE` refuses a verdict made stale by an edit), `rollback` (restore a checkpoint OF THIS SPEC and return to the phase that mode works in — WRITE_CODE, or APPLY_CONFIG for a functional spec), `diagnose`, `waive` (record — with native approval — that THIS session may work without a spec: the developer's "leave it to your judgement", kept verbatim in `.sdd/waiver.json`, reported by `status` and by the handoff; `revoke: true` drops it and needs no approval). |
| `sdd_checkpoint` | The rollback surface: `create` (snapshots the workspace, becomes the active checkpoint), `list`, `restore` (files, plus — with `restore_data=true` and `confirm_destructive=true` — the journaled data mutations: the undo runs under the company context the mutation used, turns read shapes into write values, marks each op so a retry never compensates it twice, refuses a journal from another destination, and reports every field it could not restore; it always REPORTS files created after the checkpoint and deletes them only with `remove_created=true`), `drop`, `journal`. |
| `odoo_docs` | Documentation for a module, usable **on its own** (no spec, phase, checkpoint or instance), so an existing module can simply be documented: `check` (OCA fragments mapped to Diátaxis, version scheme, changelog, `index.html`, docstrings, xpath comments, OWL directives → ERROR/WARN with `file:line`), `plan`, `scaffold` (create-only skeletons, never overwrites) and `report` (persists `docs-report.md`; APPROVED only when nothing is still a scaffold). The changelog entry is mandatory for any change to a released module. |
| `odoo_security_scan` | Local static security review (no instance needed): raw SQL by concatenation, `eval`/`exec`/`pickle`, hardcoded secrets, unjustified `sudo()`, `auth="none"`, disabled CSRF, QWeb `t-raw`. Findings carry `file:line` + a fix hint; any ERROR blocks `DONE`. |
| `odoo_manual` | Narrated user manuals built on evidence. **`canon`** reads the functional canon of a model from the instance (views, header buttons and the states that reveal them, required fields, notebook pages, actions, menus, access rules, groups, `raise UserError`/`ValidationError` and `_sql_constraints` sites) and persists it with a fingerprint: the manual may only describe what the canon says. **`plan`** turns the canon into a storyboard (flows, ordered steps, one sentence per step): re-running it MERGES, so the narration, captures and prose already written survive. **`lint`** checks the storyboard and the rendered prose against the canon (`CANON-STALE`, `TARGET-UNKNOWN`, `PLACEHOLDER-UNBOUND`, `ACTS-MISSING`, `RECOVERY-MISSING`, AI-tell patterns in Spanish and English → ERROR/WARN with the exact line). **`explore`** returns the ARIA snapshot of a real screen so no xml id or label is guessed. **`capture`** drives a browser: read-only steps always run, and action steps (`new`/`fill`/`select`/`press`/`click`) require `allow_actions=true` — which the guard treats as a mutation. **`render`** writes the manual and an Apps page (create-only unless `overwrite=true`). **`import`** turns a Playwright `codegen` recording into a flow and refuses any recording that carries credentials. Manuals live where Settings says (`manualsMode`: `specs` next to the spec, `docs` under `manualsDir`, `module` as OCA fragments under the module's `readme/manual/`); standalone work without a spec never blocks on one. **`canon`** takes the interview — `as_user` (who the manual is for), `admin`, `module`/`modules` and `groups` — and records it: the inventory then covers EVERY menu and EVERY action the module owns (the action a menu opens, even a native one), and every model arrives with its fields (label, type, required, relation/selection, groups), its form and list buttons, its statusbar states and its per-group access rows. **`plan`** opens one flow per menu and one per access-group walk, and seeds a `docs` checklist with one empty cell per field and per button. **`lint`** reports `MODULE-COVERAGE`, `INTERVIEW-MISSING`, `FIELD-COVERAGE`, `BUTTON-COVERAGE`, `WORKFLOW-COVERAGE` and `GROUP-COVERAGE` while a menu, an action, a field, a button, the workflow or a group is still uncovered — a `skip` with a reason is documented coverage, and `ROLE-UNVERIFIED` warns because a capture runs as the connected user. **`render`** adds a table per model (type, required, relation, groups, and your sentence) and a section per access group. `odoo_docs` adds a `## User manual` section to the documentation verdict as soon as a storyboard exists — and stays untouched when there is none. |
| `odoo_video` | Narrated VIDEO documentation of flows, standalone beside `odoo_manual` (it never writes `MANUAL.md`). **`plan`** drafts the storyboard from the instance canon — or, with `from_manual=<slug>`, copies an existing manual's `canon.json` + `storyboard.json` so the narrated manual becomes the video script without re-reading the instance (import-once: it refuses to overwrite a video storyboard, because video-side edits would be lost). **`voice`** narrates each step's `procedure` sentence with a TTS command YOU configure (`ttsCommand`, run without a shell; placeholders `{text}`/`{textFile}`/`{voice}`/`{out}`), one `voice/<flow>/<step>.wav` per narrated step — idempotent: an existing `.wav` is never regenerated (delete it to redo), and `voice.json` records voice + language. **`capture`** runs the flow in a real browser with the recording ALWAYS on: `video/raw/<flow>.webm` + per-step `startMs`/`endMs` timestamps + PNG stills, same gates as the manual (action steps need `allow_actions=true` AND `confirm_destructive=true` on a disposable database; `from_step` resumes after a failure). **`assemble`** cuts the webm by step, freeze-frames (tpad) to fit the narration, muxes `video/<flow>.mp4` (h264 + aac, faststart) with ffmpeg, and ALWAYS ships `.srt` + `.vtt` — the export path to external video platforms; optional `captions=true` burns them in (drawtext). The raw webm is kept: re-assembly with another voice or timing never needs a re-capture. Language is fixed at `plan` time (param > manual > `documentationLanguage` > `en_US`); no TTS configured → `NEEDS_TTS` with per-language example voices (piper-tts offline, edge-tts cloud — a cloud TTS sends the narration text to its provider, an explicit developer decision). |
| `odoo_tests` | The testing half of the pipeline, usable **on its own** (no spec, phase, checkpoint or instance): `check` scans the Python tests for what an install log cannot show — a `tests/*.py` that `tests/__init__.py` never imports (Odoo only loads what is imported, so the test never runs), a class that is not an Odoo test case, a `test_*` that asserts nothing or asserts something trivially true, a swallowed exception, `time.sleep`, explicit skips, `cr.commit()` inside a test (it breaks the per-test rollback), `SavepointCase` on 17+, an `HttpCase` without `start_tour` — with assertion density as an informational metric. `plan` maps every acceptance criterion of the spec to the test that mentions it (by AC id or by the `REQ-<AREA>-NN` id of its requirement) and lists the criteria with no test. `scaffold` writes one **RED stub per uncovered criterion** (body raises `NotImplementedError`, never a vacuous pass), create-only and idempotent, adding the import so the file actually loads. |
| `sdd_handoff` | Writes `specs/<id>/handoff.md` (final phase, verdict, decisions, blockers, checkpoints, the COMPLETE per-spec data journal, effective config, next steps) when the run closes. Only THIS spec's checkpoints are listed (other specs sharing the container are counted, not named) and the project-wide mutation allowlist is summarized instead of pasted. |
| `odoo_config` | Reads or updates the persistent configuration and answers **"which project am I in?"**: the resolved root, its provenance (session cwd / configured / process cwd), the specs base, the effective spec directory and the config file in use. |
| `odoo_i18n` | Translations through **Odoo's own models**: `base.language.export` supplies the translatable terms and `base.language.install` activates the language. `export` writes `<module>/i18n/<lang>.po` (creating the `i18n/` folder when missing) and MERGES with the file already there — a written translation is never replaced by what the export says, new terms are added, and terms the module dropped are kept as `#~` instead of deleted. `status` reports what each language covers, `check` fails while an entry still awaits its text or lacks the `#. module: <name>` comment OCA's `oca-checks-po` requires (the export writes it; Odoo's own POT does not). It does not translate: the export gives the message IDs and a person writes the text. Version-aware (Odoo 16 is the boundary, the same one that removed `ir.translation`). |
| `odoo_import` | Preparation of a CSV/XLS/XLSX import through Odoo's OWN importer (`base_import`), never through a parser of this plugin: `prepare` uploads the authorised file with the web session and its own approval, `preview` reports what Odoo reads (sheets, headers, a bounded sample, the importable fields), `map` records a decision per column, `plan` turns it into an `apply` batch — and `odoo_functional` approves and executes it like any other, so this tool never applies an import on its own. A JSONP answer is parsed as data (never executed), the session cookie never leaves the plugin, and a version outside the verified families is refused with what to investigate. |
| `odoo_functional` | The batch executor of the functional path: `plan` (validate and store a batch fail-closed), `approve` (native human approval bound to the spec, design, plan and batch hashes), `apply` (execute it one operation at a time, persisting each state before and after the call, and record it as **applied only after reading back the state the operation declared** — a call that answers but leaves the records untouched is a failure, not a success), `inspect` (read-only discovery under its own scope), `status`, `reconcile` (decide an outcome that came back as unknown), `verify` (evidence per acceptance criterion) and `compensate` (build the undo batch from the journal). The declared environment gates the run and production additionally needs a declared backup; while a batch runs, every other mutation path is denied. |

---

## 🎛️ Delegation mode

The pipeline starts by asking how much of it to delegate — recorded once per
project with `odoo_setup mode=autonomy decision=...`:

| Mode | Who answers the gates | How it ends |
|---|---|---|
| **Supervised** (default) | you, for each gated phase | you approve, or the run parks |
| **Autonomous** | a **human-proxy** agent (`resources/roles/human-proxy.md`) that emits only a fail-closed line-start `APPROVED` or `NEEDS_REVISION` | `create_goal` runs unattended rounds until `DONE` or `BLOCKED` |

> [!TIP]
> In autonomous mode the brakes stay armed: `stop.md`, the iteration ceiling and
> the diagnosis ladder all remain, and `BLOCKED` is the only way the run pages a
> human. Connection grants are *not* covered by the switch — a human still
> authorizes the instance once.

---

## 🧠 Context engineering layers

| Layer | Component |
|---|---|
| **identity** | `resources/roles/*.md` — architect, developer, qa, consultant, human-proxy, security-reviewer, documentation roles, each with its limits. Both bundled skills declare `resources/` as their `resourceBase`, so every `roles/…` and `references/…` path resolves from the base the host hands the model |
| **odoo_connection** | `odoo-client.ts` — JSON-RPC auth, `execute_kw`, session minting |
| **executors** | `odoo_module`, `odoo_execute`, `odoo_validate`, `odoo_errors` |
| **schemas** | staged templates with required sections; `transition()` rejects a phase whose deliverable lacks them |
| **knowledge** | version-pinned Odoo pattern skills (delegated, verified by the skill) |
| **skills** | `skills/*/SKILL.md` — the 5-phase development flow and the functional flow, auto-registered with the host on `apply()`. The body is the ROUTE (phases, owners, gates, exits); the per-phase WORK lives in `resources/roles/` and `resources/references/`, loaded on demand. Measured budgets keep the selected load from creeping back |
| **logbook** | `kb.json` — decisions, discarded options, blockers; read before proposing |
| **audit** | `.sdd/audit.jsonl` — sanitized append-only tool-activity log written by a global `tools/result` listener: this plugin's tools and every policy denial always, plus every other tool call when `auditAllTools` is on. A file-writing tool is recorded as path + size + sha256, and the file rotates at 16 MB keeping one generation |
| **rollback** | `.sdd/checkpoints/<id>/` — manifest + file snapshot + data journal, restorable per spec |
| **security** | `odoo_security_scan` rules + the `security-reviewer` role + the mandatory security interview in CLARIFY |
| **test** | `tests/smoke.mjs` — instance-free invariant suite (state machine, security, policy guard, RPC shapes, root/specs layout, real-Cordis host contract) + `tests/client.mjs` — browser bundle contract and settings-panel render |

---

## 🛡️ Safety, rollback and traceability

The pipeline assumes the agent will eventually be wrong, so every mutation path
has a way back and a way to prove what happened.

- **Credentials are not consent.** Before any tool opens a socket towards the
  instance, a HUMAN must have approved that exact target. `odoo_setup
  mode=authorize` asks through the host's native approval seam and only the
  `allowed-once` outcome stores a receipt in `.sdd/grants.json` (0600,
  gitignored). The receipt is bound to a fingerprint of `url + db + username`,
  so changing any of them invalidates it; `mode=revoke` drops it. Without a live
  receipt, no client is handed out at all, so a configured `.env` cannot be used
  silently. In AUTONOMOUS mode there are no answerers, so the run reports
  `NOT AUTHORIZED` and parks — which is the point.
- **The model cannot relax its own policy.** Changing the allowlist or the policy
  guards (`odoo_config mode=set`) and switching delegation mode
  (`odoo_setup mode=autonomy`) each require a native approval.
- **No change without a spec — or your explicit waiver.** Whatever the request
  ("now also change X" in the same chat), a change needs a spec in a writing
  phase (`WRITE_CODE`, `VERIFY`, `FIX_LOOP`, `APPLY_CONFIG`) or a waiver you
  approved for THAT session. The gate covers both surfaces: instance mutations
  (`odoo_execute`, `odoo_module`, `odoo_import prepare`) and source edits through
  the host's `write`/`edit` tools. Writing the spec documents themselves
  (`specs/`, `.sdd/`) is never blocked — otherwise the way out would be blocked
  too. A small change is a small spec: `mode=bug`, one acceptance criterion, no
  design interview. If you would rather not specify it, `sdd_phase
  operation=waive detail="..."` records your words, needs your approval, and
  covers this session only: the next chat starts under the policy again, and
  every change made under it still lands in the KB as a decision.
  `requireSpecForChanges: false` is the human off-switch. **Honest hole:** the
  guard sees host tool calls, so a change made through `bash` (a heredoc, `sed
  -i`) is not gated — the pipeline is the path, not a jail.

  **Where the question is asked** (`specPolicyScope`, default `"odoo"`). A spec
  requirement is only meaningful where there is Odoo work, and the gate used to
  check one thing only: whether the path fell inside the session's root. So a
  plugin installed at profile level blocked writes in folders that have nothing to
  do with Odoo — notes, a pipeline of your own, anything. With `"odoo"`, a **file
  edit** is gated only where the directory shows a sign of Odoo work:

  - `.sdd/` holding real plugin state (config, specs, checkpoints, a waiver) —
    **not** a `.sdd/` containing only `audit.jsonl`, which the audit listener
    writes for *every* tool call and which therefore means "something ran here",
    not "this is an Odoo project";
  - `__manifest__.py` in the root — the directory IS a module;
  - `__manifest__.py` one level down — the directory holds modules;
  - an `odoo_*` tool already used against that root in this session.

  An **instance mutation is always gated**: it is an `odoo_*` call, which is
  itself the evidence. `stop.md` still halts everything, adopted or not, because
  it is an emergency stop and not a scope question. `"everywhere"` restores the
  old blanket behaviour, and the refusal now names the signal that armed it rather
  than leaving you to guess.

  **Three ways out, and all three work.** (1) a small spec for this change,
  (2) continue the spec that already covers it, (3) ask the developer to waive —
  `sdd_phase operation=waive detail="..."`. The third used to require a `spec_id`,
  which made it unreachable in the one situation it exists for: a directory with
  no specs, where you would have to create one to say you did not want one. A
  waiver is per project and per session, and `spec_id` is now optional for it (and
  for `waive revoke=true`) while every other operation still demands it.
- **Checkpoint before mutating.** With `requireCheckpointBeforeMutation` on
  (default), instance mutations are denied until `sdd_checkpoint create` has
  snapshotted the active spec — and denied outright before `WRITE_CODE`. The
  rule covers `odoo_execute` (CRUD and business actions), `odoo_module
  install|upgrade`, `odoo_import use=prepare` **and `odoo_functional
  operation=apply`**, which used to be missing from that classification.
  Snapshots skip symlinks (`lstat`) and never copy `.env` or key material.
- **Fail-closed guard.** An internal guard failure denies with a visible reason
  instead of allowing the call through. The guard is also a MOUNT requirement
  while either policy is armed: a host that exposes no `tools.guard` (or whose
  guard cannot be installed) makes the plugin refuse to mount with the reason and
  the way out, instead of staying up and looking protected.
- **The canonical phase decides.** Authorization reads the phase from
  `specs/<id>/state.json`; the pointer only says WHICH spec and checkpoint the
  session works on. A stale pointer can no longer authorize a change for a spec
  that has already gone `BLOCKED`.
- **The pointer belongs to the session.** It lives in
  `.sdd/active/<sessionId>.json`, so two chats open on one project no longer
  overwrite each other and neither can authorize its changes against the other's
  spec — the failure this scoping exists for. `.sdd/active.json` remains only as
  the project's *last* spec: a new session adopts it as a **hint** when it is
  fresh (< 12 h) and names a spec that still exists, and otherwise starts with no
  spec at all. Checkpoint *snapshots* stay shared (they are the project's), while
  which checkpoint is *your* rollback target is per session; dropping one clears
  the reference in every pointer that held it.
  Set **`adoptProjectPointerHint: false`** to remove the hint entirely: every
  session then starts with no spec until it declares one (`sdd_phase status
  spec_id=…`), which is the strictest reading of "one session, one spec".
- **File rollback.** `sdd_checkpoint restore` puts the snapshotted files back
  byte-for-byte; `sdd_phase rollback` returns the spec to the phase its MODE
  works in — `WRITE_CODE`, or `APPLY_CONFIG` for a functional spec — with the
  failure recorded, so the loop restarts from a known state. It refuses a
  checkpoint that belongs to another spec. A checkpoint
  snapshots the project tree, so with the **central** specs layout the spec
  documents (which live outside the project) are deliberately not part of it:
  the spec is the immutable source of truth, not code to roll back.
- **Data rollback (best effort, and honest about it).** Every
  `create`/`write`/`unlink` through `odoo_execute` records its pre-image in the
  checkpoint journal, stamped with the database **and** the destination
  (url+db+user) it was applied to; `restore restore_data=true
  confirm_destructive=true` replays it in reverse and refuses a journal from
  another destination. The replay runs under the company context the mutation
  used, converts read shapes into write values (many2one, x2many), marks each
  operation as it is compensated so a retry never repeats one, and reports the
  fields it could not restore (binary content, read-only or non-stored fields).
  Re-created records get NEW ids — the report says so. This covers data written
  through the plugin — **not** side effects of a module install/upgrade, which
  are not reverted at database level.
- **Restore reports drift.** `restore` always lists the files created *after* the
  checkpoint, so nothing is silently left behind; `remove_created=true` deletes
  them (inside the snapshotted roots only) to match the snapshot exactly.
- **Documentation is a gate, not a footnote.** ARCHITECTURE records the decision
  in `## Documentation`, WRITE_CODE produces the OCA fragments + the Apps
  `index.html` + the mandatory changelog entry, and `DONE` is gated by
  `documentationPolicy` (`required` by default; `optional` and `off` available).
  What the plugin cannot do, it says so: `gen-odoo-readme`, `towncrier`, Ruff and
  pylint need a shell, so the fragments are the source of truth and compiling
  `README.rst` stays your step.
- **Lifecycle: the plugin owns its state and can give it back.** `odoo_setup
  mode=purge` prints a plan (what it owns and what it deliberately keeps) and
  deletes only after `confirm_destructive=true` plus native human approval. It
  never touches `.env` (your credentials), `stop.md` (your brake) or `specs/`
  (your documents).
- **Durable state.** Pipeline state, KB, verdicts, grants and the journal are
  written with an atomic replace, and a corrupt file is quarantined next to the
  original instead of being overwritten: `sdd_phase status` reports the recovery.
- **Traceability.** `.sdd/audit.jsonl` records each call with outcome
  (`ok` / `error` / `denied`), duration and phase; `sdd_phase status` prints the
  logbook; `sdd_handoff` freezes the whole run into `handoff.md`. A file body is
  not copied into the log — it is recorded as `{bytes, sha256}` — and `bash`,
  `write` and `edit` are only logged when `auditAllTools` is on.
- **Emergency brakes.** `stop.md` (at `.sdd/stop.md` or `specs/<active>/stop.md`)
  halts every tool; iteration ceilings and the diagnosis ladder route to
  `BLOCKED` instead of looping forever.

---

## 🔐 Security & network posture

- **No phone-home.** The plugin's ONLY outbound network calls go to the instance
  URL you put in `.env`. No telemetry, no update checks, no third-party
  endpoints.
- **Transport guard (fail-closed).** `http://` is accepted only for loopback
  hosts (`localhost`, `127.x`, `::1`, `*.localhost`); anything else must be
  `https://`, otherwise credential loading is refused — plain http to a remote
  host would ship the API key in clear text.
- **Secret containment.** The secret is read once by `credentials.ts` and only
  injected into RPC parameters. Every tool output passes through two-layer
  redaction (known secret + generic `password=` / `Bearer` / `api_key=` /
  `session_id` shapes) and home-path masking (`/home/user/…` → `~/…`) before
  being shown to the model or persisted to the KB.
- **Session cookies never reach the model.** `odoo_session` writes the cookie to
  `.sdd/session.json` (chmod 600) and returns only the path. The capture motor
  does not even write one: it authenticates the web session in memory and keeps
  the cookie there for the browser context.
- **The capture browser is locked to the instance origin.** Every request that
  leaves the instance's base URL is aborted, so a page that tries to reach a
  third-party host fails instead of loading. Capture is read-only by default:
  steps that only observe (`goto`, `menu`, `wait`, `assert`, `explore`,
  `screenshot`, `confirm`) always run; steps that act (`new`, `fill`, `select`,
  `press`, `click`) require `allow_actions=true` AND `confirm_destructive=true`,
  the guard classifies that call as a mutation (spec in a writing phase or a
  waiver) — and **a click has no compensating entry**: unlike a CRUD call, the
  plugin cannot undo what the UI did. Capture against a disposable database.
- **A recording is read as data, and credentials are refused.** `odoo_manual
  operation=import` parses the `codegen` text; a recording carrying a password
  field, an `Authorization` header or a cookie is rejected by line, and the
  value is never echoed back.
- **A credential file is not written while git can still see it (fail-closed).**
  Before scaffolding a `.env`, the plugin asks git itself — `git check-ignore`
  for the ignore rules and `git ls-files` for the index — and refuses with
  `status: needs-gitignore` when a protected path is missing from `.gitignore`
  **or is already tracked**, in which case the only fix it names is
  `git rm --cached`. An ignore rule never protects a file that is in the index,
  and that gap is exactly how a `.sdd/.env` with a live password reached a
  remote branch once. Where git is absent, the fallback reads the `.gitignore`
  text and says so instead of claiming proof. Opt out per project with
  `requireGitignoreBeforeCredentials: false`.
- **No install scripts beyond the build.** The only lifecycle scripts are
  `prepare`/`prepack`, which compile `src/` into the shipped `lib/` and do
  nothing else — no network, no `postinstall`, no shell. The two have opposite
  failure policies on purpose: **`prepare` (which runs on an install) never
  fails one** — without the devDependencies or the optional host peers it says
  what it could not check and emits without type checking so the plugin still
  loads — while **`prepack` (which runs on a publish) refuses to package a build
  it could not typecheck**. `prepack` is also what guarantees a published tarball
  is never missing the entry point its `main` promises (that failure was real: a
  clean clone packed 39 files and zero of them under `lib/`; so was its sequel,
  a `prepare` that typechecked during `npm install` and failed every CI job
  before the step that installs those peers could run).
- **Host requirement declared twice**, following dsh-market discovery
  conventions: `engines.dsh` and lockstep optional peer ranges on
  `@deepseek-ai/{cordis,dsh-tools,schemastery}`. On a host without the `tools`
  service the plugin refuses to mount with an explicit error instead of booting
  broken.

---

## 🖥️ Platform support

The plugin runs wherever DSH does and claims **Linux, macOS and Windows** — a
claim the CI matrix tests rather than asserts (`ubuntu-latest` **and**
`windows-latest`, on Node 20, 22 and 24).

| Concern | Behaviour |
|---|---|
| Paths | `module_dir` and an explicit root accept POSIX (`/opt/odoo`) and Windows (`C:\odoo`, UNC) spellings; an absolute path is never concatenated under the project root. |
| `.env` permissions | Owner-only (0600) is requested and **re-checked after `chmod`**: on a filesystem that cannot express mode bits (Windows, FAT/exFAT, some mounts) the plugin says "owner-only mode requested" and adds a note instead of pretending the file is private. On a real POSIX filesystem, a loose mode that cannot be tightened is still refused. |
| Atomic writes | State files are written to a sibling temp file and renamed into place, retrying `EPERM`/`EACCES`/`EBUSY` with a bounded backoff — the case where Windows refuses the rename because an editor, indexer or antivirus holds an open handle. |
| Project root | Resolved per call from the **session's folder**; the process cwd is only a last resort and is reported as `LAST RESORT`. |
| Symlinks | A checkpoint never follows a symlink out of the tree; the test suite skips its symlink assertions where the OS or the user privileges forbid creating one — and says so instead of passing silently. |

### 📁 Where specs live

The project root is the folder open in the current session, so it is not a
plugin-wide setting. Spec documents follow it:

| Layout | Path | Pick it when |
|---|---|---|
| **project** (default) | `<projectRoot>/<specsDir>/<specId>` | specs should travel with the code |
| **central** | `<specsRoot>/<projectSlug>/<specId>` | you keep many module repos and want one searchable place |

In the central layout each project gets its own subfolder with a
`.dsh-project-root` marker, plus a hash suffix if two projects share a directory
name — a foreign folder is never adopted. `.sdd/` always stays with the project.

```text
<projectRoot>/
├── .sdd/                       # plugin-owned, gitignored
│   ├── .env                    # credentials, single-target layout (chmod 600)
│   ├── instances/<name>.env    # one file per Odoo target (chmod 600)
│   ├── instances/active.json   # which target is in use
│   ├── config.json             # project configuration
│   ├── grants.json             # human authorization receipts
│   ├── session.json            # Playwright cookie
│   ├── audit.jsonl             # tool activity, sanitized (digests for file bodies)
│   ├── setup-state.json        # onboarding + delegation decision
│   ├── active/<sessionId>.json  # THIS session's pointer (spec, phase, checkpoint)
│   ├── active.json             # the project's last spec (a hint for new sessions)
│   └── checkpoints/<id>/       # manifest + file snapshot + data journal
└── specs/<NNN>-<slug>/         # or the central folder
```

> [!TIP]
> With several projects open, read the `Project root: … [provenance]` line that
> every tool result carries, or ask `odoo_config mode=read` — it returns the
> resolved root, its provenance and the effective spec directory.

---

## ⚙️ Configuration

Open **Settings → Odoo SDD** in the Web UI. Everything is editable there, plus a
few copy-paste presets where you need them.

> **Host requirement:** the plugin mounts only on **dsh >= 0.2.2-rc.1** (that is
> `engines.dsh`, and it is the only version field the host enforces). The Settings
> panel itself has needed **dsh >= 0.2.0-rc.1**: that host
> removed the `settingsScope` service this panel used to bind to, and it now
> renders the form from the plugin's own `Config` schema (entry `odoo-sdd`). On an
> older host the section is absent and the plugin says so in the console; the tools
> and the pipeline keep working, and `odoo_config mode=read|set` edits the same
> values from the conversation.


<p align="center">
  <img src="assets/settings-panel.jpg" alt="Settings → Odoo SDD: where specs live, who approves the phases, licensing, and the mutation allowlist" width="640" />
</p>

```yaml
# ~/.dsh/profiles/<profile>/cordis.patch.yml (optional: same fields, as a patch)
- insert:
    - id: odoo-sdd
      config:
        specsMode: project      # project | central
        specsRoot: ''           # absolute folder when specsMode=central
        specsDir: specs         # folder inside the project when specsMode=project
        executeAllowlist: []    # models odoo_execute may create/write/unlink
        methodAllowlist: []     # "model.method" pairs a functional batch may call (any model)
                                # the ad-hoc RPC needs no list: any public method + confirm_destructive
        communityRepoUrl: https://github.com/odoo/odoo
        enterpriseRepoUrl: https://github.com/odoo/enterprise
        communityRepoPath: ''   # local OS path; when set it OVERRIDES communityRepoUrl
        enterpriseRepoPath: ''  # same for enterprise
        odooApi: auto           # auto | json2 | jsonrpc (auto falls back on a 401)
        autonomy: supervised    # supervised | autonomous
        licensed: community     # community | enterprise (OCA is always searched)
        requireCheckpointBeforeMutation: true
        requireGitignoreBeforeCredentials: true   # refuse a .env while git would still see it
        requireSpecForChanges: true     # every change needs a spec in a writing phase (or a waiver)
        specPolicyScope: odoo           # odoo (only where Odoo work is detected) | everywhere
        securityReviewRequired: true
        securityInterviewRequired: true
        auditAllTools: false    # also log every OTHER tool call (file bodies as a digest)
        maxCheckpoints: 5
        documentationPolicy: required   # required | optional | off
        documentationLanguage: ''       # empty = English unless the project says otherwise
        manualsMode: specs              # specs (next to the spec) | docs (under manualsDir) | module (readme/manual/)
        manualsDir: docs/manuals        # used only when manualsMode is docs
        videosDir: docs/videos          # where odoo_video writes <slug>/ workspaces (inside the project root)
        ttsCommand: ''                  # narrated video ONLY. External TTS, no shell; placeholders
                                        #   {text} {textFile} {voice} {out}. Examples:
                                        #   edge-tts (cloud): edge-tts --voice {voice} --text {text} --write-media {out}
                                        #   piper  (offline): piper --model {voice} --output_file {out}   (uses {textFile})
        ttsVoice: ''                    # passed as {voice}; example voices: es-ES-ÁlvaroNeural (edge) / es_ES-sharvard-medium (piper)
        ffmpegPath: ffmpeg              # video assembly (system ffmpeg, 4.x+; 7.x verified) and ffprobe next to it
```

> [!IMPORTANT]
> There are two configuration stores, and the more specific one wins:
> **Settings** (user-wide, `~/.dsh/settings.yaml`) and the project's
> **`.sdd/config.json`** (written by `odoo_config mode=set`, per project). If a
> key looks ignored after you change it in the panel, the project file is
> pinning it — `odoo_config mode=read` reports the effective values.

---

## 🤖 Model experience

The agent sees 19 tools with self-contained descriptions. Typical flow:
`sdd_phase init` → security interview + `odoo_connect` → gated phases with
`APPROVED` → `sdd_checkpoint create` → code → `odoo_security_scan` →
`odoo_module install` → on traceback, `odoo_errors` + `sdd_phase fail` (which may
force a diagnosis) → fix (or `sdd_phase rollback`) → re-verify →
`sdd_phase succeed` → `sdd_handoff` → `DONE`. Tool responses are actionable text:
server tracebacks, gate rejection reasons and remediation instructions.

The workflow skill is registered at mount time, so its **name and description**
appear in every session's skill catalog automatically. The full instructions load
when the model selects it (the catalog is summaries only) or when you type
`/odoo-sdd-workflow`.

---

## ⚠️ Known limitations and deferred work

- **Remote tests** — without shell access to the instance there is no way to run
  `--test-enable`; the second verification layer is RPC/UI testing. Pending: an
  optional `odoo_run_tests` tool if you expose a test runner.
- **Data rollback is best effort** — the tests write to the connected database and
  there is no ephemeral cloning (by design: you provision and own the target).
  `sdd_checkpoint` reverses data written through `odoo_execute`, but a module
  install/upgrade is **not** reverted at database level. Use a disposable
  database.
- **Static security scan scope** — `odoo_security_scan` is rule-based over source
  text (no AST, no taint tracking), so it catches the common Odoo mistakes, not
  everything; it complements a human review, never replaces it.
- **The change policy covers tool calls, not the shell** — `write`/`edit` and the
  Odoo tools are gated; a file written through `bash` (heredoc, `sed -i`) is not,
  because the host exposes no after-the-fact inspection of what a shell command
  wrote. The policy makes the pipeline the path of least resistance and the
  exception visible; it is not a sandbox.
- **Multi-instance** — one target per project (`.env`). Pending: named instance
  profiles (`dev`, `staging`).
- **Panel fields that are informational** — `autonomy` and
  `securityInterviewRequired` are stored and reported, but the pipeline reads the
  delegation decision from `.sdd/setup-state.json` (set with `odoo_setup
  mode=autonomy`) and enforces the security interview through the ARCHITECTURE
  content gate. Tracked as pending work.
- **Manual capture needs a browser and a password** — `playwright-core` is an
  optional dependency and the browser itself is not shipped: without Chromium,
  Chrome or Edge the capture answers `NEEDS_BROWSER` with the install line. A
  web session also needs the account **password** (Odoo refuses an API key on
  interactive logins), so an API-key-only project can still write and lint a
  manual but cannot drive the screenshots automatically.
- **The manual covers one model at a time** — the canon is read per model, so a
  cross-model walkthrough is several flows planned against several canons, and
  the storyboard keeps the fingerprint of the canon it was planned with
  (`CANON-STALE` warns when the instance moved).
- **UI families 15, 16, 18 and 19 are declared, not proven** — the capture
  selectors are verified end-to-end on 17; the other families share the same
  structure but must be checked against a dev instance before you rely on them,
  and `familyFor` refuses a major it does not know instead of guessing.
- **Prose lint is advisory** — the AI-tell rules are heuristics and every one of
  them is a WARN by design in v1: only the structural rules (one sentence per
  captured step, no heading inside a procedure) and the canon rules can block.
- **No rich UI renderer** — tool output is text in the DSH web GUI.

---

## ❓ Troubleshooting

| Symptom | What it means | What to do |
|---|---|---|
| `NOT CONFIGURED` | no usable `.env` in the cascade | `odoo_setup mode=interactive` |
| `NEEDS_SECRET` | the scaffold exists but `ODOO_PASSWORD` is empty | fill it in the file, never in chat |
| `NOT AUTHORIZED` | credentials exist, no live human grant for this target | `odoo_setup mode=authorize` |
| `Instance unreachable` | version probe failed | check the URL/port and that the instance is running |
| Mutations always denied | no checkpoint, or the spec is not in `WRITE_CODE` yet | approve the gates, then `sdd_checkpoint create` |
| Everything is halted | `stop.md` exists | read it, then remove it |
| A panel change seems ignored | the project's `.sdd/config.json` outranks the global Settings layer | `odoo_config mode=read` shows the effective values |
| A spec directory "cannot be found" | you are in a different project folder | open that project's folder in a session |
| `NEEDS_BROWSER` from `odoo_manual capture` | no Chromium/Chrome/Edge was found | `npx playwright-core install chromium`, or pass `browser: "chrome"` |
| `NEEDS_WEB_SESSION` from `odoo_manual capture` | the target only has an API key, and Odoo refuses one on interactive logins | put an account password in `.env`, or create `.sdd/session.json` with `odoo_session` |
| `NEEDS_TTS` from `odoo_video voice` | no `ttsCommand` is configured (or the template needs `{voice}` and none is set) | configure `ttsCommand` (example voices per language are in the message: piper-tts offline, edge-tts cloud) and `ttsVoice` |
| `NEEDS_FFMPEG` from `odoo_video assemble` | the system `ffmpeg` (config `ffmpegPath`) is missing or fails to run | install ffmpeg 4.x+ (7.x verified) or point `ffmpegPath` at its absolute path |
| `NEEDS_VIDEO_CAPTURE` from `odoo_video assemble` | no `capture.json` with a video entry for that flow (capture ran without recording, another flow, or never ran) | run `odoo_video operation=capture flow=<id>` first — assembly always reads the recorded webm |
| `no view rendered within 30s` from `odoo_video capture` | the URL is not a webclient action, or the instance is unreachable from the browser | open the action once with `odoo_manual operation=explore` or fix the target xmlid; capture waits for the view AND its records, never for `load` alone |
| A video that starts on a blank/white screen | playback of a capture taken before this check existed, or the webm cut by hand | re-capture: assembly keeps only the tail of a `goto` window and records any lead-in as `captureOffsetSeconds` in `video/video.json` |
| video capture fails asking for Playwright's ffmpeg | recording uses Playwright's BUNDLED ffmpeg, a download separate from chromium | `npx playwright install ffmpeg` (or `npx playwright-core install ffmpeg`) once |
| The captured screen is in the wrong language | the browser follows the login user's language, and the plugin never writes to `res.users` | capture with a user whose language matches the manual |
| `allow_actions=true` is refused | the guard classifies it as a mutation | move the spec into a writing phase, or record a waiver — and use a disposable database |
| A capture stops midway | the first failing step aborts the run and writes what it got | fix the storyboard, then re-run with `from_step: "<the failing step id>"` |
| `odoo_docs` says `NEEDS_CONTENT` with no fragment missing | a manual ERROR (a screenshot without its image, a stale canon) or a scaffold left in place | read the `## User manual` section of the report |
| `dsh: pnpm was not found` on install | `dsh plugin` delegates to pnpm, which is not bundled with Node | `npm i -g pnpm` (Node 25 removed `corepack`), then retry `dsh plugin add` |
| `Unsupported engine` or `plugin … is not compatible` | the host enforces `engines.dsh` alone: a DSH older than **0.2.2-rc.1** is refused; pnpm also enforces that host's own `node >= 24.9.0` | upgrade DSH (and Node) — the plugin never runs on a host it was not tested against |
| `ERR_MODULE_NOT_FOUND … scripts/prepare.mjs` during `npm rebuild` | a tarball shipped before 0.19.0 packed no build hook, while `npm rebuild` runs `prepare` | reinstall the package (the hook now ships and is a no-op when `lib/` is prebuilt) |

---

## 🧩 Implementation internals

<details>
<summary>Plugin shape, source map and security decisions — click to expand</summary>

### Plugin shape

Follows the DSH tool-plugin convention (`dsh-tool-todo`, `dsh-tool-goal`): named
exports `name`, `inject`, `Config` (schemastery schema) and `apply(ctx, config)`,
registering each tool with `defineTool` from `@deepseek-ai/dsh-tools`. The
browser half is a plain JS ModuleLoader bundle that contributes the **Odoo SDD**
settings section.

### Source map

| File | Role |
|---|---|
| `src/index.ts` | Plugin entry: registration of the 19 tools, config resolution and the policy guard |
| `src/types.ts` | Public payload types (never contain secret material) |
| `src/credentials.ts` | Credential cascade, `.env` load/validation, permission verification, `redact()`, fail-closed |
| `src/odoo-client.ts` | JSON-RPC client: `common.version`, `authenticate`, `execute_kw`, `button_immediate_*`, `ir.logging`, `/web/session/authenticate` |
| `src/test-scan.ts` | Static rules for the module's Python tests (unimported test module, case class, assertion density, swallowed errors, `cr.commit`, sleeps, skips, `SavepointCase` by version) |
| `src/tests-tool.ts` | The `odoo_tests` tool (`check`/`plan`/`scaffold`), usable without the pipeline |
| `src/tools-runtime.ts` | Odoo-facing tool bodies: `odoo_execute` (allowlist + pre-image capture), `odoo_validate`, `odoo_module`, `odoo_errors` |
| `src/sdd-state.ts` | Phase machine, gates, append-only KB, verdicts, security content gate, `stop.md` |
| `src/spec-reqs.ts` | Stable `REQ-<AREA>-NN` ids, per-requirement fingerprints, `req-baseline.json` and DRIFT detection |
| `src/coverage.ts` | Bidirectional coverage between spec and module: code annotations vs declared requirements, orphan detection, modality floors, status ladder |
| `src/ambiguity-lint.ts` | Advisory wording lint for `spec.md` (unquantified adjectives, placeholders, passives, compounds) |
| `src/checkpoints.ts` | Checkpoint store: manifest, file snapshot/restore, data journal, purge budget |
| `src/security-scan.ts` | Instance-free static security rules (`scanModule`) with `file:line` findings |
| `src/audit.ts` | Sanitized append-only audit log (`.sdd/audit.jsonl`) and the `withAudit` wrapper |
| `src/setup-state.ts` | Onboarding decision + delegation mode persistence (`.sdd/setup-state.json`) |
| `src/grants.ts` | Human authorization receipts (`.sdd/grants.json`), fingerprint-bound and fail-closed |
| `src/atomic.ts` | Atomic writes (bounded retry on `EPERM`/`EACCES`/`EBUSY`) and corruption quarantine + recovery reporting |
| `src/paths.ts` | Cross-platform `module_dir` resolution |
| `src/specs-location.ts` | Where specs live: project vs central layout, session-root resolution with provenance, slug/marker/collision handling |
| `src/lifecycle.ts` | Ownership inventory and the `purge` primitive (own state only; never `.env`/`stop.md`/`specs/`) |
| `src/docs-scan.ts` | Documentation rules: OCA fragments + Diátaxis, version scheme, changelog, index.html, docstrings, xpath, OWL |
| `src/docs-tool.ts` | The `odoo_docs` tool (check/plan/scaffold/report), usable without the pipeline |
| `src/project-conventions.ts` | Resolves the documentation language from the project's own rules, defaulting to English |
| `src/manual-model.ts` | Manual workspace resolution (3 modes, containment-checked), storyboard read/write with stable ids, plan from the canon, canon-rule validation, step safety classes |
| `src/manual-canon.ts` | The functional canon: XML arch tokenizer (17 `invisible=` expressions, 14–16 `attrs`/`states`, statusbar states), module error scan, per-version read list (`load_views` ≤15, `get_views` 16+, `groups_id`→`group_ids` in 19) and a stable fingerprint |
| `src/manual-tool.ts` | The `odoo_manual` tool: canon / plan / lint / render / explore / capture / import, with the create-only policy and the resume-after-failure path |
| `src/video-model.ts` | Video workspace resolution (`videosDir`, containment-checked), the lighter storyboard validation video needs (canon staleness, empty flows, unknown targets, silent steps), and `from_manual` import (copies canon + storyboard, never touches the manual) |
| `src/video-voice.ts` | Narration: template split with quoting, TTS spawn without a shell, idempotent per-step `.wav` files, `voice.json` with probed durations, per-language example voices for the `NEEDS_TTS` remediation |
| `src/video-assemble.ts` | ffmpeg assembly: per-step cut + freeze-frame (tpad) to the narration length, concat demuxer, faststart mux, always-shipped SRT/VTT, optional burned-in captions, `video.json` report |
| `src/video-tool.ts` | The `odoo_video` tool: plan (instance or `from_manual`) / voice / capture (recording always on, same gates as the manual) / assemble |
| `src/prose-lint.ts` | Advisory prose rules in Spanish and English (AI-tell patterns, sentence length, readability) plus the two structural ERROR rules the OCA format depends on |
| `src/manual-render.ts` | Deterministic Markdown + Apps-page rendering, per-file `sha256`, and a hand-edited file that is never overwritten unless `overwrite=true` |
| `src/manual-capture.ts` | The browser motor: origin lock, read-only-first step gating, annotated PNGs (1440×900, overlay removed), ARIA snapshots, `capture.json` |
| `src/manual-import.ts` | Playwright `codegen` dialect reader: canon label mapping, generic steps with WARN instead of guesses, credential rejection by line |
| `src/manual-audit.ts` | Read-only audit that lets the documentation verdict cover an existing manual (capture coverage, canon staleness, prose health) and change nothing when there is none |

### Security decisions

- The secret only exists inside `credentials.ts` and the RPC call parameters;
  every output passes through `redact()` (including `user:pass@` URL shapes).
- The transport guard rejects anything that is neither HTTPS nor loopback before
  authenticating.
- Mutations are fail-closed twice over: the allowlist is read live on every call,
  and the policy guard denies `create`/`write`/`unlink` unless a checkpoint
  exists and the spec is in `WRITE_CODE` or later.
- Secrets are never accepted as tool parameters, never written to the audit log,
  and never requested through chat.
- Corrupted state ⇒ restart (progress is never faked); ambiguous gate ⇒
  rejection; absent verification ⇒ `DONE` unreachable; security gap ⇒
  ARCHITECTURE gate rejected.

### Build and test

```bash
npm run typecheck   # tsc --noEmit
npm run build       # emits lib/ (required: package main is lib/index.js)
npm test            # server invariants + Cordis host contract + client bundle + README + functional/import
npm run test:package  # tarball contents (a file the runtime loads but `files` omits)
```

These are exactly the steps [CI](.github/workflows/ci.yml) runs on Linux and
Windows, so a local `npm run typecheck && npm test` reproduces the pipeline.

`test:package` also **simulates the publish**: it copies the package without
`lib/` (what a fresh clone has), runs `npm pack` on it and asserts the tarball
still contains the compiled entry point. That is the check that keeps a released
version from being installable-but-unloadable. It then simulates the **install**
side — a tree with the compiler but without the optional host peers, which is
exactly CI's `npm install` — and asserts that `prepare` exits 0 there while
`prepack` refuses.

### Publishing (maintainers)

**npm does not follow GitHub.** They are two independent registries: a push, a tag
or a GitHub Release updates GitHub and nothing else, and `npm publish` updates npm
and nothing else. A published version is **immutable** — it cannot be overwritten,
only superseded — so `package.json` is bumped for every release.

`lib/` is build output and is gitignored, so the tarball is built by the `prepack`
hook — never by hand, never from a stale tree. The **first** publication is manual
(npm only lets you register a trusted publisher for a package that already
exists):

```bash
npm login                 # once; then `npm whoami` should answer
npm publish               # prepack runs `tsc` and packs the result
npm view dsh-odoo-sdd version   # verify what the registry actually has
```

After that, [`.github/workflows/publish.yml`](.github/workflows/publish.yml) does
it: **publishing a GitHub Release** (or a manual *workflow_dispatch*) runs the
same gates as CI — typecheck, build, tests, the publish simulation — checks that
the release tag matches `package.json`, refuses a version that is already on the
registry, and publishes with **provenance** through npm's OIDC **trusted
publishing**, so no `NPM_TOKEN` lives in this repository.

One-time setup for that automation: on npmjs.com → the package → *Settings* →
*Trusted publishers* → *Add* → provider **GitHub Actions**, owner `fhidalgodev`,
repository `dsh-odoo-sdd`, workflow filename `publish.yml`, environment **blank**
(a mismatch is a `403 npm-trusted-publisher-not-configured`). Prefer a token?
Create a granular access token with *bypass 2FA* and store it as the `NPM_TOKEN`
secret — the workflow says where.

So a release is: bump the version → merge → publish the Release, and the tag, the
Release and the npm version end up agreeing.

The same hooks make a repository install work: `prepare` compiles the sources
when TypeScript is present (`npm i github:fhidalgodev/dsh-odoo-sdd`), and skips
with a notice when it is not (a `file:` install has no 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>

> Chart generated live by the [star-history.com](https://star-history.com) API.

## 🙏 Acknowledgments

Built for the Odoo developer community, this plugin rests on three ecosystems:

- **[Odoo Community Association (OCA)](https://github.com/OCA)** — the coding
  conventions, module layout and quality gates this pipeline enforces.
- **[DeepSeek Harness (DSH)](https://github.com/deepseek-ai)** — the plugin
  architecture (Cordis tools/plugins, skills, subagents) this plugin runs on.
- **[Playwright](https://playwright.dev)** — the browser motor behind
  `odoo_manual operation=capture`, and the `codegen` recordings the import reads.

The manual writing guide draws on published documentation practice rather than
inventing a house style: **Diátaxis** (the fragment quadrants the OCA readme
already follows), **Information Mapping** and **John Carroll's minimalism** for
task-oriented procedures, **ISO/IEC 26514** for the structure of user
documentation, and the **StoryBrand / "lead with insight"** school of narrative
framing for the opening act. The anti-AI prose list was informed by public
storytelling and content-skill collections (notably
[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) and
[lyndonkl](https://github.com/lyndonkl/claude)) — the rules that survived are
the ones that catch a real tic without punishing a clear sentence.

Thank you to everyone who contributes patterns, reviews and ideas that shape the
SDD workflow. Contributors:

<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>

---

## 📜 License

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