dsh-odoo-sdd — Spec-Driven Development for Odoo
Spec-Driven Development for Odoo
Turn DeepSeek Harness into a closed-loop Odoo workshop:
spec → architecture → code → verify, against a real instance
🇬🇧 English •
🇪🇸 Español •
🇨🇳 简体中文
Author: Franyer Hidalgo — fhidalgo.dev@gmail.com
⭐ If this plugin saves you time, a star helps a lot — it is the signal that keeps the pipeline maintained.
🐛 Found a bug or want a feature? Open an issue in any language. Reproducible reports and honest "this did not work" notes are the most useful thing you can send.
|
⚡ 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
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
(): 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. →
🚀 Quick start
1. Install into a profile
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?
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:
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:
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.
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.
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
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:
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):
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.
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":
- 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.
- 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.
- 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.
- Apply: one operation at a time, re-checking those hashes, persisting each
operation's state before the call and after the result.
- 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.
- 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:
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_*, …, e.g. confirming an order) runs with and , because the plugin cannot replay its effect — it is never journaled, no undo exists for it, and a timeout is reported as an outcome instead of a plain failure — the same rule now applies to CRUD, because a whose answer never arrived may already have committed and reporting it as a retryable failure is how one effect becomes two. Declare / (the same shape a batch uses: ) 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, only means the server accepted the call. A () is refused, and that wall is Odoo's own: dispatches through , which answers for , , and the internal attribute names — so read the model in the source (the native/custom repositories are reported by ) and call the public button or action that wraps it ( is private; the wizard exposes the public ). For several operations, or when a human approval per run is wanted, use a functional batch with . is forwarded verbatim — use / 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 // — and for a mutation it explicitly forbids repeating the call to get more text. |
🎛️ 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) + — 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 , a is gated only where the directory shows a sign of Odoo work:
🔐 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 , the plugin asks git itself —
for the ignore rules and for the index — and refuses with
when a protected path is missing from
, in which case the only fix it names is
. An ignore rule never protects a file that is in the index,
and that gap is exactly how a with a live password reached a
remote branch once. Where git is absent, the fallback reads the
text and says so instead of claiming proof. Opt out per project with
.
🖥️ 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.
<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.
Settings → Odoo SDD: where specs live, who approves the phases, licensing, and the mutation allowlist
# ~/.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 refuses a major it does not know instead of guessing.
❓ 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 |
🧩 Implementation internals
Plugin shape, source map and security decisions — click to expand
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, and DRIFT detection |
⭐ Star History
Star History Chart
Chart generated live by the star-history.com API.
🙏 Acknowledgments
Built for the Odoo developer community, this plugin rests on three ecosystems:
- Odoo Community Association (OCA) — the coding
conventions, module layout and quality gates this pipeline enforces.
- DeepSeek Harness (DSH) — the plugin
architecture (Cordis tools/plugins, skills, subagents) this plugin runs on.
- Playwright — 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,
yaxeen/storytelling-skills,
Donchitos/team-narrative,
fabric and
lyndonkl) — 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:
Contributors to fhidalgodev/dsh-odoo-sdd
📜 License
MIT © Franyer Hidalgo