<p align="center">
  <img src="assets/odoo-sdd.svg" alt="dsh-odoo-sdd — 面向 Odoo 的规范驱动开发" width="800" />
</p>

# 面向 Odoo 的规范驱动开发

<div align="center">

<h3>把 DeepSeek Harness 变成一个闭环的 Odoo 工坊：<br/>spec → 架构 → 代码 → 验证，全程面对真实实例</h3>

<!-- npm 徽章指向已发布的包。如果这个项目被 fork 到别处，
     请更新 GitHub 徽章里的 owner/repo。 -->
<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>作者：</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>如果这个插件帮你省下了时间，一颗 star 就是很大的帮助</strong> — 它是让这条流水线持续维护下去的信号。
      <br><br>
      🐛 <strong>发现了 bug，或者想要某个功能？</strong> 用任何语言提 issue 都可以。可复现的报告，以及诚实的"这个不管用"的说明，是你最有价值的反馈。
    </td>
  </tr>
</table>

</div>

---

## ⚡ 概述

`dsh-odoo-sdd` 把 **DeepSeek Harness** 变成一条规范驱动（SDD）的 Odoo
开发流水线。有两个理念把它撑起来：

- **闭环反馈** —— agent 会安装和升级模块、读取服务器 traceback，并针对一个
  **真实运行中**的 Odoo 实例重试。插件从不启动 Docker 或 `odoo-bin`：你通过一个
  被 gitignore 的 `.env` 把它指向你已有的实例（dev/staging），工具之间只说
  标准 JSON-RPC。
- **流水线安全** —— 每个阶段都持久化到磁盘，门禁在缺少显式 `APPROVED` 标记时
  fail-closed，连续三次失败会强制做根因诊断，verify/fix 迭代次数有上限，
  `stop.md` 会中止一切，而且结论是诚实的：失败的验证会持久化为 FAILED，
  永远不可能被报告成成功。
- **没有 spec 就没有变更** —— 在流水线关闭*之后*才到来的请求是一次新的变更，
  哪怕它出现在同一个对话里（"顺便把 X 也改了"）：它要么得到自己的（小份）spec，
  要么得到你显式的 waive。这道守卫对实例变更和源码编辑一视同仁，并且会说明三条
  出路里哪一条可用，而不是悄悄改掉。

> [!NOTE]
> **它不是什么：** 不是基础设施编排器，不是通过聊天管理凭据的管家，也不是
> 自动提交器。它不写任何 commit，也从不向你索要密码。

### 环境要求

| 需求 | 原因 |
|---|---|
| **DSH ≥ 0.2.2-rc.1**，运行在 **Node ≥ 20** 上 | 插件以 Cordis bundle 的形式挂载，并使用 `tools` 服务；该宿主自身要求 **Node ≥ 24.9.0**，因此实际运行时的下限是 Node 24（插件代码本身可在 20 上运行） |
| 一个可通过 HTTP(S) 访问的**现有 Odoo 实例** | 闭环需要一台真实服务器来安装模块并读取 traceback |
| 一个**可丢弃的 dev/staging 数据库** | 验证过程会安装模块并写入测试数据 |
| *（可选）* 一个 **Playwright** 浏览器工具 | 仅用于验证的 UI 层以及叙述式手册的截图；没有它时，这些场景会被标记为需要人工检查 |
| *（可选）* `playwright-core` ≥ 1.63.0 | 作为可选依赖随插件发布；截图引擎会驱动 Chromium、Chrome 或 Edge，一个都找不到时会返回 `NEEDS_BROWSER` 并附上一行补救命令 |

---

## 🔭 工作原理

```mermaid
flowchart TD
    A(["想法或需求"]) --> C1["1 CLARIFY<br/>访谈与安全问题"]
    C1 --> R2["2 READ_SPEC<br/>不可变的 spec.md"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>模型、视图、安全、测试计划"]
    A3 -->|APPROVED| W4["4 WRITE_CODE<br/>模块源码与 OCA 文档"]
    W4 --> V5["5 VERIFY<br/>静态、安装、RPC、UI"]
    V5 -->|PASSED| D9(["handoff.md - DONE"])
    V5 -->|FAILED| F6["FIX_LOOP<br/>根因，最多 5 次迭代"]
    F6 --> V5
    W4 -.-> M7["odoo_module install 或 upgrade"]
    M7 -.->|traceback| E8["odoo_errors"]
    E8 -.-> F6
```

规范是唯一的真相来源，而且它**不可变**：代码去适应 spec，绝不反过来。门禁默认由
人来回答；当你选择自主模式时，也可以把它们委派给一个人工代理 agent。

---

## ✨ 核心特性

- 🎯 **永远先有 spec，再写代码。** `spec.md` 携带编号的验收标准；磁盘上没有
  持久化的 `PASSED` 结论，就永远到不了 `DONE`。
- 🧬 **稳定的需求标识。** 每个需求块获得一个稳定的 `REQ-<AREA>-NN` id（章节 slug + 序号），
  外加一枚提交到 `req-baseline.json` 的指纹。在已有 id 之下改写需求措辞就是 **DRIFT**：
  在你回退它或显式接受它（`sdd_phase operation=baseline`）之前，推进会被拒绝，这样一份
  计划或一个测试就绝不会悄悄引用一条已经不再那么说的需求。
- 🔗 **双向覆盖。** 用 `# REQ-<AREA>-NN` 标注代码和测试，`odoo_validate module_dir=… spec_dir=…`
  会同时检查两个方向 —— 没有实现的需求，以及 spec 已不再声明的孤立标注。下限：
  `must`/`shall` 在 1.0 处阻断，`should` 在 0.8 处报告，`may` 仅作信息。每条需求都会落在
  `covered / untested / test-only / unimplemented` 这架梯子上。
- 🔍 **歧义 lint（建议性）。** `mark_spec_loaded` 报告没有量化的形容词、含糊的量词、
  `TBD` 占位符、没有施动者的被动句以及 and/or 复合句，每一条都给出具体改写 —— 只是建议，
  绝不是门禁。
- 🧪 **真的会失败的测试。** `odoo_tests` 检查安装日志看不见的测试套件：Odoo 从不导入的
  测试文件（没有任何东西加载它）、什么都不断言的测试、被吞掉的异常、破坏逐测试回滚的
  `cr.commit()`、`time.sleep`、跳过 —— 并把每一条验收标准映射到提到它的测试。`scaffold`
  写出 RED 桩（在实现之前一直抛错），而不是空洞的通过。
- 🔴 **修 bug 时先 RED 后 GREEN。** 在 `mode=bug` 下，流水线拒绝离开 `WRITE_CODE`，
  直到 `test-plan.md` 记录了**先失败**的复现（`red (evidence: …)`，修复后则是
  `pass (red first: …)`）—— 一个从未失败过的回归测试证明不了任何东西。
- 🔁 **真实的反馈闭环。** `odoo_module install` 返回服务器自己的输出或
  traceback；`odoo_errors` 读取 `ir.logging`；失败会变成一个持久化的 FAILED
  结论，而不是一份乐观的总结。
- 🧾 **不只是模块。** 同一套机器也会跑**功能型** spec（`mode=functional`）：
  配置一个在线实例，并以人工批准的批次加载数据，CSV/Excel 走 Odoo 自己的
  导入器，最后用一份别人能照着复现的 runbook 收尾。
  → [功能路径](#-功能路径配置与导入)
- 🔒 **有凭据不等于有授权。** 一个项目打开第一个 socket 之前，需要一份绑定
  `url + db + user` 的显式人工授权（`.sdd/grants.json`）。
- ⏪ **说真话的回滚。** checkpoint 会快照文件，并记录每一次 `odoo_execute`
  的前像；restore 总会报告 checkpoint 之后创建的文件。撤销不了的东西，它会直说。
- 🧱 **文档也是门禁。** `odoo_docs` 产出 OCA 的 `readme/` 片段、Apps 的
  `index.html` 和强制的 changelog 条目 —— 而且它对一个没有 spec、没有阶段、
  没有实例的已有模块同样可用。
- 🧪 **无需实例的静态层。** `odoo_validate`（结构 + ACL 一致性）和
  `odoo_security_scan`（原生 SQL、`sudo()`、`auth="none"`、QWeb `t-raw`……）
  在任何东西被安装之前就给出带 `file:line` 的发现。
- 📖 **手册像故事一样写，像代码一样检查。** `odoo_manual` 从实例读取一个模型的
  功能 canon（按钮以及显示它们的状态、必填字段、notebook 页、菜单、访问规则、
  确切的 `raise UserError` 位置），规划出分镜，用 canon 校验散文，**默认只读**地
  截取带标注的截图，并渲染出一份人能照着走的手册 —— 每条指令配一张带标注的
  截图，并在 canon 表明该按钮可能报错时给出恢复路径。
- 🎬 **同样证据的配音视频。** `odoo_video` 把同一份分镜变成一段配音 screencast：
  每一步的 `procedure` 句子就是配音台词（由**你**配置的 TTS 命令朗读 —— 离线
  piper-tts 或云端 edge-tts）和字幕，`capture` 录制浏览器会话并给每一步打上
  时间戳，`assemble` 用 ffmpeg 剪辑、定格并封装出 `video/<flow>.mp4`。
  SRT/VTT 字幕总是随片交付（通往任何视频平台的导出路径）；原始 webm 会被
  保留，重新配音绝不需要重新录制。
- 🧑⚖️ **模型无法自行放松的策略。** 白名单、守卫和委派模式，任何改动都需要
  原生人工批准。
- 🤖 **受监督或自主。** 同一条流水线，可以由人逐个回答门禁，也可以由人工代理
  agent 无人值守地跑 goal 轮次，直到 `DONE` 或 `BLOCKED`。
- 📁 **spec 放在你想要的地方。** 放在每个项目旁边，或者把每个项目的 spec
  收集到一个可搜索的文件夹里。
- 🖥️ **Linux、macOS 和 Windows。** 路径、原子写入和 `.env` 权限都按平台处理，
  并在 CI 的 Windows 上测试。

---

## 🚀 快速开始

### 1. 安装到 profile

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

那会从 **npm registry 安装已发布的包** —— 不用 clone，不用 build，你这边没有
任何要编译的东西。`dsh plugin` 是一个很薄的 `pnpm` 转发器：它在 profile 目录里
运行 `pnpm add`，然后注册 bundle（`dsh.profile.bundles`）。有两个后果值得知道：

- **pnpm 必须在你的 `PATH` 上**（不在时 `dsh plugin` 会报出来）。
- 任何 pnpm spec 都能用，所以你可以锁定版本：
  `dsh plugin --profile web add dsh-odoo-sdd@0.20.0`。

更想用原生 npm —— 一个依赖这个插件的项目，或者一个 CI job？

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

> [!IMPORTANT]
> 安装后请重启 DSH 并刷新浏览器标签页。客户端改动（**Odoo SDD** 设置面板）
> 从已安装的包里加载。

**正在改这个插件本身？** 那就安装这份 checkout。`lib/` 是构建产物，**不**提交；
`npm install` 会通过 `prepare` 钩子构建它，你也可以随时显式要求：

```bash
git clone https://github.com/fhidalgodev/dsh-odoo-sdd && cd dsh-odoo-sdd
npm install          # devDependencies: typescript，随后 prepare 构建 lib/
npm run host:deps    # 可选 peers，编译时需要（no-save）
npm run build        # 生成 lib/ —— 必需，包的 main 是 lib/index.js
dsh plugin --profile odoo add .
```

> [!NOTE]
> 从 **git** 安装会在使用者的机器上跑那次 `prepare` 构建，而 pnpm 会阻止依赖的
> 构建脚本，直到你允许它们：命令会告诉你需要在 profile 的
> `pnpm-workspace.yaml` 的 `allowBuilds` 下加入的确切键名。从 registry 安装
> 完全不需要这些 —— tarball 里已经带了 `lib/`。

`dsh plugin add` 会把 bundle 记录进 profile 的 `package.json`
（`dsh.profile.bundles`），而这个包自带一个 Cordis 补丁
（`cordis.patch.yml`），它会插入自己的那一行 —— 所以没有手工组装步骤。
`dsh --profile <name> --dump-config` 会打印组装后的配置树，而不启动任何东西。

### 2. 给它凭据（每个项目一次）

让 agent 运行 `odoo_setup mode=check`。它会写一个**不含密钥**的脚手架，
密码由你自己填：

```text
odoo_setup mode=interactive url=http://localhost:8069 db=odoo_dev username=admin
# 然后在打印出来的文件里填写 ODOO_PASSWORD（推荐使用 Odoo API key）
odoo_setup mode=authorize   # 只问你一次，授权这个确切的目标
```

或者手动来 —— 插件会使用下列位置中第一个存在的：

| # | 位置 | 范围 |
|---|---|---|
| 1 | `ODOO_SDD_ENV_FILE` | 显式的环境变量覆盖 |
| 2 | `<project>/.sdd/instances/<name>.env` | **每个目标一个文件**，当前激活的实例 |
| 3 | `<project>/.sdd/.env` | 单目标布局（也会作为实例 `default` 出现） |
| 4 | `~/.config/dsh-odoo-sdd/.env`（遵循 `$XDG_CONFIG_HOME`） | 用户范围 —— 一套开发凭据供所有项目使用 |
| 5 | `<project>/.env` | 旧位置，仍然支持（会被标记为 legacy） |

**一个项目里的多个目标。** 需要本地 CE 服务器、EE 预发服务器和客户实例的
工作区，不再覆盖唯一的 `.env`：每个目标在 `.sdd/instances/` 下有自己的文件，
其中一个是激活的。

```bash
odoo_setup mode=instance instance=list                       # 有哪些，非机密身份
odoo_setup mode=instance instance=add name=staging url=https://stg.example.com db=stg username=ci
# 在 .sdd/instances/staging.env 中手工填写密钥，然后：
odoo_setup mode=authorize name=staging                       # 授权**那个**目标
odoo_setup mode=instance instance=use name=staging            # 为**本会话**激活它
odoo_setup mode=instance instance=remove name=staging         # 需要 confirm_destructive=true
odoo_setup mode=revoke name=staging                           # 只撤销该目标的授权
```

**激活的目标属于「会话」，不属于项目。** 每个对话保存自己的指针
（`.sdd/instances/active-<sessionId>.json`），因此同一项目上的两个会话可以同时使用
两个环境 —— 一个在预发，另一个在客户的服务器上 —— 互不影响。
**未做选择的会话会被「询问」，而不会得到默认值。** 新对话中的第一次 Odoo 操作会被拒绝
并返回该问题，其中包含可用目标及其非机密身份：

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

继承项目最近使用的目标只在第一次方便，之后就很危险：另一个会话切换环境会静默地把正在
工作的会话重定向，而它的工具回答仍然只是 `OK`。每个会话问一次即可消除这一点。
`.sdd/instances/active.json` 仅作为问题中显示的*建议*保留。没有具名实例的项目
（只有 `.sdd/.env`，单目标布局）永远不会被询问，而 `requireInstanceChoice: false`
可为偏好者恢复旧的继承行为。

授权是**按目标**的（`url`+`db`+`user`），因此同一台服务器上的两个实例是两个授权。
**`authorize` 接受目标的名称**，这正是让切换可恢复的关键：授权会在 12 小时后过期，
而"必须先激活才能授权"曾是一个死循环 —— `use` 要求目标有有效授权，而 `authorize`
只能看到当前激活的那个。直接命名即可打破循环：无需离开 `main` 就能授权 `staging`。

激活一个没有有效授权的目标仍会被拒绝：指针决定变更发往哪里，所以它不能指向
没有人批准过的目标。当存在**多个**实例且**没有**激活任何一个时，所有连接工具
都会拒绝，而不是按顺序挑选 —— 旧的 `.sdd/.env` 会作为 `default` 原样继续工作，
直到你自己决定迁移。

```bash
mkdir -p ~/.config/dsh-odoo-sdd && cd ~/.config/dsh-odoo-sdd
cp <plugin>/.env.example .env && chmod 600 .env
# 填写：ODOO_URL, ODOO_DB, ODOO_USERNAME，以及**一个**密钥：
#   ODOO_PASSWORD=<账户密码>   或   ODOO_API_KEY=<API 密钥>
```

**密钥有两个被接受的名字。** 会读取 `ODOO_PASSWORD` 和 `ODOO_API_KEY`，推荐使用
API 密钥：Odoo 在通过 RPC 接受密码的任何地方都接受它（设置 → 用户 → API 密钥，
并使用全局范围的密钥）。如果两者都设置了，`ODOO_PASSWORD` 优先，插件会**报告**
另一个被忽略，这样轮换后的凭据不会被漏掉。

只有一个地方 API 密钥**不**适用，这是 Odoo 的规则而非插件的规定：`odoo_session`
和 `odoo_import` 认证的是 **Web 会话**，而 Odoo 以 `interactive: True` 认证交互式
登录，仅在相反情况下才查询 API 密钥表。若需要这两者，请配置账户密码；其他一切
都可以用密钥完成。

### 插件使用哪个 API

| 服务器 | 传输 | 凭据 |
|---|---|---|
| **Odoo 19+** | `POST /json/2/<model>/<method>` —— 具名参数，`Authorization: Bearer` | API 密钥 |
| 任意版本 | `POST /jsonrpc` —— `execute_kw` | 密码**或** API 密钥 |

在 19+ 上插件使用现代的 JSON-2 API：没有 `uid` 往返，具名参数由 Odoo 自身校验，
`@api.readonly` 方法路由到只读副本，错误以 HTTP 状态加结构化响应体返回。当路由缺失
在三种情况下会自动回退到经典 JSON-RPC：路由缺失、服务器版本更旧，或者**该凭据在那里根本
不可用**。最后一种很重要：JSON-2 只接受 `Authorization: Bearer`，因此用**密码**配置的目标每次调用都会
返回 `401` —— 而 `401` 意味着请求从未执行，所以改用另一传输方式不会造成重复副作用。服务器**确实处理过**
并拒绝的调用（`403`、`422`、领域错误）**绝不**重发。
`json2` 会报告路由缺失，而不是静默降级。

业务动作（`action_*`、`button_*`、`do_*`）始终使用 `execute_kw`：JSON-2 没有位置
参数形式，而为插件并不知道签名的方法臆造位置→名称映射，正是它拒绝做出的那种静默
误发。

> [!WARNING]
> 绝不要把密码粘贴到聊天、spec、commit 或 issue 里。插件会拒绝组/其他用户可读的
> `.env`，在每一次工具输出中脱敏密钥，并把会话 cookie 存到 `.sdd/session.json`
> （mode 600），从不把它们返回给模型。

### 3. 提出你的需求

```text
为 Odoo 19 实现一个销售订单审批模块，使用 SDD 工作流。
```

agent 会从会话的 skill 目录里取出 `odoo-sdd-workflow` 并遵循协议。如果你想
说得更明确 —— 或者想确保完整指令被加载 —— 把消息以 `/odoo-sdd-workflow` 开头。

如果要在运行中的实例上做配置和数据工作，请改成这样说：

```text
在我的 dev 实例里配置公司、税和会计科目表，然后导入
这个 customers.csv —— 功能型 SDD，dev 环境，不要碰生产。
```

那会选择 `odoo-functional-sdd`（或显式写 `/odoo-functional-sdd`）
以及下面描述的 `functional` spec 模式。

---

## 🧭 五个阶段

| 阶段 | 会发生什么 | 离开它的门禁 |
|---|---|---|
| **CLARIFY** | 记录意图（`mode` create/bug、`licensed`，以及可选的 `translations` —— 见下文）并回答安全访谈：组、ACL、记录规则、`sudo()` 的理由、公共路由 | `sdd_phase clarify` |
| **READ_SPEC** | 吸收 `spec.md`：业务背景、编号的验收标准、约束、目标 Odoo 版本。**在此阶段写代码是被禁止的。** | `APPROVED` + `mark_spec_loaded` |
| **ARCHITECTURE** | 模型、视图（在重要之处包含额外的视图类型和 search 视图）、报表、安全矩阵和 `test-plan.md` | `APPROVED` |
| **WRITE_CODE** | 用按版本固定的 Odoo 模式实现模块及其 OCA 文档 | 静态门禁全绿 |
| **VERIFY** | 递增金字塔：静态 → 安装/升级 → RPC/数据 → 仅对关键流程做 UI（Playwright） | 持久化的 `PASSED` 结论 |
| **FIX_LOOP** | 根因修复。连续 3 次失败强制顾问诊断；5 次迭代强制 `BLOCKED` | 诚实的结论 |

### 字符串要翻译吗？（`translations`）

CLARIFY 会连同授权问题一起询问，答案记录**在 spec 上** —— 不是全局设置，与版本选择完全一致。
同一个工作区可以既有委内瑞拉的客户项目，也有完全不交付语言的内部修复。

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

- **省略 `translations` 意味着该工作不翻译。** 这是一个正常答案，因此与 `mode` 和 `licensed`
  不同，它**不是** fail-closed 关卡。
- 代码是 **gettext** 形式（`ll` 或 `ll_CC`：`es_VE`、`es_PA`、`pt_BR`、`fr`）。格式错误的代码会
  在还能纠正时被拒绝，而不是变成一个服务器永远不会加载的文件。
- 声明语言会让 `odoo_i18n` 进入计划，并在 `test-plan.md` 中加入对应的验收标准：
  *"存在 `i18n/<lang>.po` 且没有导出的条目留有空 `msgstr`"* —— 由
  `odoo_i18n operation=check` 验证。
- `sdd_phase status` 会报告它们，因此"我们翻译了吗？"无需打开 `state.json` 即可回答。

在 ARCHITECTURE 阶段，agent 还会**主动询问**那些"早决定很便宜、晚发现很昂贵"的
事情：form/tree 之外的**额外视图类型**（包括用于描述模型如何被搜索的
**search 视图** —— 自定义过滤器、收藏夹）、**报表**（通过
`ir.actions.report`/QWeb 的 PDF、SQL、CSV/XLSX、外部工具）、**web tours**
（onboarding、测试，或都不需要 —— 并说明加载它的 asset bundle，因为没有任何
bundle 加载的 tour 永远不会执行）以及**演示数据**（哪些文件、用途是什么）。每一项
都要明确回答："form + tree only"、"no reports needed"、"no tours needed"、
"no demo data"。这些是**指引性**决定：记录在 `## Views` / `## Reports` /
`## Tours` / `## Demo data` 中，并在 `sdd_phase status` 里作为警告呈现，按设计不
阻塞 —— 安全模型才是唯一 fail-closed 的内容门禁。各版本的 tour API、执行它的
`HttpCase` 以及演示数据的陷阱都在
`resources/references/tours-and-demo.md`。

每个 spec 的产物（全部在磁盘上，可断点续跑）：

```text
specs/<NNN>-<slug>/
├── spec.md · architecture.md · test-plan.md
├── verify-verdict.txt   # 持久化的诚实结论
├── state.json           # 阶段、失败次数、迭代次数
├── kb.json              # 决策、被弃用的选项、blockers、诊断
├── docs-report.md · security-report.md
└── handoff.md           # 运行收尾时由 sdd_handoff 写入
```

---

## 🧩 功能路径（配置与导入）

不是每一项 Odoo 工作都是写代码。搭建一家公司、它的税、它的用户和它的主数据，
属于**配置与数据**，而且发生在在线实例上 —— 在那里，一次误点不是失败的测试，
而是一条真实记录。同一套 SDD 机器用一个不同的中间阶段和更严格的收尾规则覆盖它。

```mermaid
graph TD
    C1["1 CLARIFY<br/>目标、实例、ENVIRONMENT"] --> R2["2 READ_SPEC<br/>spec.md、标准、来源"]
    R2 -->|APPROVED| A3["3 ARCHITECTURE<br/>to-be 流程、批次、风险"]
    A3 -->|APPROVED| X4["4 APPLY_CONFIG<br/>发现 + 已批准的批次"]
    X4 --> V5["5 VERIFY<br/>重新读取记录，逐条 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
```

| | 开发型运行 | 功能型运行 |
|---|---|---|
| 在 `CLARIFY` 选择 | `mode=create` 或 `mode=bug` | `mode=functional` |
| 中间阶段 | `WRITE_CODE`（模块源码） | `APPLY_CONFIG`（针对实例的批次） |
| 交付物 | 模块 + OCA 文档 | 配置好的实例 + `functional-runbook.md` |
| Skill | `odoo-sdd-workflow` | `odoo-functional-sdd` |

**一次改动是如何到达实例的。** 没有任何东西是"写着看看会怎样"：

1. **先做发现**，在它**自己**的审批之下：哪些模型、哪些字段、多少条记录。读取
   不是变更，但一个被批准的 scope 才能阻止"只是看看"变成一次改动。
2. **计划**：设计变成批次。每个批次声明它的目标和环境、用到的版本和能力、公司
   和上下文、它覆盖的验收标准、有序的操作、记录标识、前置条件、预期结果、风险、
   恢复方式和手工步骤。
3. **批准**：人看到确切的批次，并通过原生审批通道批准它。回执绑定 spec、设计、
   计划和批次的哈希 —— 改动其中任何一个，审批即失效。
4. **应用**：一次一个操作，重新校验那些哈希，在调用**之前**和结果**之后**分别
   持久化每个操作的状态。
5. **未知结果不是重试。** 一次变更之后的超时可能意味着 Odoo 已经提交了，所以
   该操作会被标记为 `indeterminate`，批次停止，运行挂起，直到有人来对账。
6. **诚实地收尾**：`sdd_phase succeed` 要求每条验收标准都有明确的 `pass`，安全
   审查是强制的，**runbook** 也是强制的（谁来做、在哪个公司、前置条件、验证过的
   菜单路径、带字段标签的步骤、预期结果、如何检查以及如何撤销）—— 无论文档策略
   怎么说。

**环境必须声明，绝不假设。** 目标上的 `ODOO_SDD_ENVIRONMENT` 会说 `dev`、
`staging` 或 `production`。声明了与目标不同环境的计划会被拒绝
（`environment-mismatch`），未声明环境的目标会被要求补上
（`NEEDS_ENVIRONMENT`），而生产环境额外需要一份声明的备份引用以及它自己的审批。
高风险改动先在 staging 中验证。

**导入走 Odoo 自己的导入器，绝不走手写解析器：**

```text
odoo_import use=prepare file=... model=res.partner   # 上传文件，带它自己的审批
odoo_import use=preview   ...                        # ODOO 读到了什么：工作表、表头、样本
odoo_import use=map       ...                        # 每一列一个决定，不能留空
odoo_import use=plan      ...                        # 变成一个 `apply` 批次
odoo_functional operation=approve / apply            # 批次路径，保持不变
```

版本契约是显式的（主版本 10–19：旧端点上用 `file`/`import_id` + JSONP，新端点上用
`ufile`/`id` + JSON，应用时用 `do`/`execute_import`），不在已验证版本族内的版本会被
**拒绝**并告知该调查什么，而不是靠猜。上传之后被改动的文件会让映射失效；应答里
出现 `nextrow` 意味着导入器在文件中途停下了，会被报告为部分完成，绝不会报告为
成功 —— 而它已经计入的行绝不会被重发。会话 cookie 留在 `.sdd/session.json`
（mode 600）里，不进入任何工具结果。

> [!NOTE]
> 功能路径需要一个实例，而导入器需要一个 web 会话：在
> `odoo_import use=prepare` 之前先运行一次 `odoo_session`。

---

## 🧰 19 个工具

| 工具 | 用途 |
|---|---|
| `odoo_connect` | 探测实例：服务器版本 + 认证。报告经过掩码处理；区分 `NEEDS_SETUP` / `NEEDS_SECRET` / `DEFERRED` / `SKIPPED` 状态（从不在聊天中索要密钥）。 |
| `odoo_setup` | 上手引导：`check`（级联 + gitignore + 委派模式）、`interactive`（不含密钥的 chmod 600 脚手架）、**`authorize`**（通过原生审批向开发者本人索要一个绑定当前 url/db/user 的连接授权）、**`revoke`**（撤销授权）、**`purge`**（先给出计划，然后在 `confirm_destructive=true` 加人工批准的前提下，只删除插件自己在 `.sdd/` 下的状态）、`later`、`skip`、`reset`、`autonomy`（supervised \| autonomous，需人工批准）。密钥永远不会作为参数被接受。 |
| `odoo_module` | 对 `ir.module.module` 执行 `info` / `install` / `upgrade`（`button_immediate_*`）。原样返回服务器自己的输出或 traceback，并做脱敏 —— 这就是闭环反馈。 |
| `odoo_execute` | 针对实例的通用 RPC（`execute_kw`）：**任意模型的任何公共方法都可调用**，而它需要什么由它是什么决定。读操作（`search`、`search_read`、`search_count`、`read`、`read_group`、`fields_get`、`name_get`、`name_search`、`default_get`、`exists`、`check_access_rights`、`check_access_rule`）可自由执行，并可用 `fields`/`limit`/`order`/`offset` 做投影和分页（小数或负数的 `offset` 会被拒绝，绝不被截断）；CRUD 变更操作（`create`/`write`/`unlink`）需要 `confirm_destructive=true` **且**模型在 `executeAllowlist` 中，并且会被记入日志，以便数据撤销时重放它们；每一个**业务动作**（`action_*`、`button_*`、`do_*`……，例如确认一张订单）都需要 `confirm_destructive=true` 且**无需白名单**，因为插件无法重放它的效果 —— 它永远不会被记入日志，对它不存在撤销，而超时会报告为一个 **INDETERMINATE** 结果，而不是普通的失败 —— 同一条规则现在也适用于 CRUD：一个从未收到应答的 `create` 可能已经提交，把它报告成可重试的失败正是「一个效果变成两个」的方式。声明 `precondition`/`postcondition`（与批次使用的形状相同：`{domain, expect, count}`），在调用之前带上状态守卫、在调用之后带上状态证明：如果前置条件失败，什么都不会发出；如果后置条件失败，应答会说明调用**确实已发出**、实例可能只改了一半，而 CRUD 变更会保留它的日志回执（新建 id + 前像），因此仍然可以被补偿；没有它们时，`OK` 只意味着服务器接受了这次调用。**私有方法**（`_name`）会被拒绝，而这堵墙是 Odoo 自己的：`execute_kw` 通过 `get_public_method` 分发，后者对 `_…`、`init`、`@api.private` 和内部属性名会回答 `AccessError` —— 所以要在源码里读该模型（原生/自定义仓库由 `odoo_config mode=read` 报告），并调用包装它的公共按钮或动作（`sale.order._create_invoices` 是私有的；`sale.advance.payment.inv` 向导暴露了公共的 `create_invoices()`）。要执行多个操作，或者希望每次运行都有人工审批时，请使用带 `kind: "method"` 的功能批次。`context` 原样转发 —— 在多公司实例上用 `allowed_company_ids`/`company_id` —— 服务器仍然会应用它自己的 ACL。 |
| `odoo_validate` | 本地、无需实例的模块结构检查：`__manifest__.py` 是否存在 + depends、声明的数据 XML 文件是否存在（含 `views/*.xml`）、有模型时是否有 `security/ir.model.access.csv`，以及项目自身 `.pylintrc` 要求的 OCA 元数据（`manifest-required-keys`、`manifest-required-authors`，外加入 `license`/`version`、`website`、`README.rst`、`static/description/index.html`）。视图约定：`position="replace"` 缺少 `priority`、使用 `t-esc`（应改为 `t-out`）。返回 file:line 级别的发现，以及它解析出的 `module_dir` 和项目根目录（相对路径按会话所在文件夹解析，绝不按进程 cwd 解析）。 |
| `odoo_errors` | 读取最近的 `ir.logging` 服务器错误 —— 相当于远程拉取环境日志。 |
| `odoo_session` | 铸造一个无密码的 web 会话（`connect_as_user` 模式），存放在 `.sdd/session.json`（chmod 600），供 Playwright UI 测试使用。cookie 本身永远不会被返回。 |
| `sdd_phase` | 阶段状态机：`init`、`status`（logbook 摘要、spec 目录和 specs 位置）、`mark_spec_loaded`、`advance`（fail-closed 门禁：原生审批由宿主询问人类，`approval_marker`/`approval_source` 保留为没有审批接口的宿主所记录的兜底路径）、`fail`（失败阶梯 + FAILED 结论）、`succeed`（PASSED 结论；在 VERIFY/FIX_LOOP 之外会被拒绝，并且除非 SPEC 声明的每个 AC 在 `test-plan.md` 中都有一行明确写着 `pass` —— 被省略的标准不等于通过的标准；结论会记录所用证据的指纹，因此 `DONE` 会拒绝被编辑改旧的结论）、`rollback`（恢复**本 SPEC 的** checkpoint，并回到该模式实际工作的阶段 —— WRITE_CODE，或功能型 spec 的 APPLY_CONFIG）、`diagnose`、`waive`（记录 —— 在原生审批下 —— **本会话**可以在没有 spec 的情况下工作：开发者那句"你自己看着办"会原样保存在 `.sdd/waiver.json` 里，由 `status` 和 handoff 报告；`revoke: true` 会删除它，且不需要审批）。 |
| `sdd_checkpoint` | 回滚面：`create`（对工作区做快照，成为活动 checkpoint）、`list`、`restore`（恢复文件，并在 `restore_data=true` 和 `confirm_destructive=true` 时恢复已记录的数据变更：撤销会在该变更用过的公司上下文中运行，把读取形态转换成写入值，标记每个操作以免重试时重复补偿它，拒绝来自其他目标的日志，并报告每一个它无法恢复的字段；它**总是报告** checkpoint 之后创建的文件，且只有 `remove_created=true` 才会删除它们）、`drop`、`journal`。 |
| `odoo_docs` | 模块文档，可**独立使用**（不需要 spec、阶段、checkpoint 或实例），因此一个已有模块也能直接被文档化：`check`（把 OCA 片段映射到 Diátaxis、版本方案、changelog、`index.html`、docstring、xpath 注释、OWL 指令 → 带 `file:line` 的 ERROR/WARN）、`plan`、`scaffold`（只创建、绝不覆盖的骨架）和 `report`（持久化 `docs-report.md`；只有当不存在仍是骨架的片段时才是 APPROVED）。对已发布模块的任何改动都必须写 changelog 条目。 |
| `odoo_security_scan` | 本地静态安全审查（不需要实例）：拼接式原生 SQL、`eval`/`exec`/`pickle`、硬编码密钥、没有理由的 `sudo()`、`auth="none"`、被关闭的 CSRF、QWeb `t-raw`。发现项带有 `file:line` + 修复提示；任何 ERROR 都会阻止 `DONE`。 |
| `odoo_manual` | 基于证据的叙述式用户手册。**`canon`** 从实例读取一个模型的功能 canon（视图、header 按钮以及显示它们的状态、必填字段、notebook 页、动作、菜单、访问规则、用户组、`raise UserError`/`ValidationError` 与 `_sql_constraints` 位置）并连同指纹持久化：手册只能描述 canon 说过的话。**`plan`** 把 canon 变成一份分镜（流程、有序步骤、每步一句话）：再次运行会**合并**，已经写好的叙述、截图和散文都会保留。**`lint`** 用 canon 校验分镜和渲染后的散文（`CANON-STALE`、`TARGET-UNKNOWN`、`PLACEHOLDER-UNBOUND`、`ACTS-MISSING`、`RECOVERY-MISSING`、西语和英语的 AI 腔模式 → 带确切行号的 ERROR/WARN）。**`explore`** 返回真实屏幕的 ARIA 快照，因此不会去猜任何 xml id 或标签。**`capture`** 驱动浏览器：只读步骤总是执行，而动作步骤（`new`/`fill`/`select`/`press`/`click`）需要 `allow_actions=true` —— 守卫会把它当作一次变更。**`render`** 写出手册和一个 Apps 页面（只创建，除非 `overwrite=true`）。**`import`** 把 Playwright `codegen` 录制转换成一条流程，并拒绝任何携带凭据的录制。手册存放在「设置」指定的位置（`manualsMode`：`specs` 与 spec 同目录、`docs` 在 `manualsDir` 下、`module` 作为模块 `readme/manual/` 下的 OCA 片段）；没有 spec 的独立工作绝不会因此被阻塞。**`canon`** 接收访谈——`as_user`（手册写给谁）、`admin`、`module`/`modules` 与 `groups`——并把它记录下来：清单随即覆盖该模块拥有的**每一个菜单和每一个动作**（菜单打开的动作，即使是原生的），每个模型都会带上它的字段（标签、类型、必填、关联/选择、用户组）、表单和列表按钮、statusbar 状态以及按用户组的访问行。**`plan`** 为每个菜单开一条流程、为每个用户组的巡览开一条流程，并播种一份 `docs` 清单：每个字段、每个按钮一格空白。**`lint`** 在菜单、动作、字段、按钮、工作流或用户组仍未被覆盖时报告 `MODULE-COVERAGE`、`INTERVIEW-MISSING`、`FIELD-COVERAGE`、`BUTTON-COVERAGE`、`WORKFLOW-COVERAGE` 与 `GROUP-COVERAGE` —— 带理由的 `skip` 属于已记录的覆盖，而 `ROLE-UNVERIFIED` 只是警告，因为截图是以已连接用户的身份跑的。**`render`** 为每个模型加一张表（类型、必填、关联、用户组，以及你写的那句话）并为每个访问组加一节。只要分镜已存在，`odoo_docs` 就会在文档结论中加入 `## User manual` 一节 —— 没有分镜时则保持不变。 |
| `odoo_video` | 流程的配音**视频**文档，与 `odoo_manual` 并列的独立工具（绝不写 `MANUAL.md`）。**`plan`** 从实例 canon 起草分镜 —— 或者用 `from_manual=<slug>` 复制既有手册的 `canon.json` + `storyboard.json`，让叙述式手册直接变成视频脚本而无需重读实例（import-once：拒绝覆盖已有的视频分镜，因为视频侧的编辑会丢失）。**`voice`** 用**你**配置的 TTS 命令朗读每一步的 `procedure` 句子（`ttsCommand`，无 shell 执行；占位符 `{text}`/`{textFile}`/`{voice}`/`{out}`），每个被叙述的步骤一个 `voice/<flow>/<step>.wav` —— 幂等：已存在的 `.wav` 绝不重新生成（删掉即可重做），`voice.json` 记录语音与语言。**`capture`** 在真实浏览器中执行流程，录制**始终开启**：`video/raw/<flow>.webm` + 每步 `startMs`/`endMs` 时间戳 + PNG 定格，与手册相同的门禁（动作步骤需要 `allow_actions=true` 且 `confirm_destructive=true`，且只在一次性数据库上；`from_step` 在失败后恢复）。**`assemble`** 按步剪辑 webm、按配音时长定格（tpad）、用 ffmpeg 封装 `video/<flow>.mp4`（h264 + aac、faststart），并且**总是**交付 `.srt` + `.vtt` —— 通往外部视频平台的导出路径；可选 `captions=true` 把字幕烧录进画面（drawtext）。原始 webm 会保留：换一种语音或节奏重新装配绝不需要重新录制。语言在 `plan` 时固定（参数 > 手册 > `documentationLanguage` > `en_US`）；未配置 TTS → `NEEDS_TTS` 并附按语言的示例语音（离线 piper-tts、云端 edge-tts —— 云端 TTS 会把配音文本发给其提供商，这是开发者的显式决定）。 |
| `odoo_tests` | 管道的测试一侧，可**独立使用**（不需要 spec、阶段、checkpoint 或实例）：`check` 扫描 Python 测试中安装日志看不到的问题 —— `tests/__init__.py` 从未导入的 `tests/*.py`（Odoo 只加载被导入的模块，因此该测试从不运行）、不是 Odoo 测试用例的类、没有任何断言或断言恒真的 `test_*`、被吞掉的异常、`time.sleep`、显式 skip、测试内的 `cr.commit()`（破坏每个测试的回滚）、17+ 上的 `SavepointCase`、没有 `start_tour` 的 `HttpCase`，并以断言密度作为参考指标。`plan` 把 spec 的每个验收标准映射到提到它的测试（按 AC id 或按其需求的 `REQ-<AREA>-NN`），并列出没有任何测试的标准。`scaffold` 为每个未覆盖的标准写一个 **RED stub**（函数体抛出 `NotImplementedError`，绝不是空洞的通过），只创建且幂等，并追加 import 让文件真正被加载。 |
| `sdd_handoff` | 在运行收尾时写入 `specs/<id>/handoff.md`（最终阶段、结论、决策、blockers、checkpoint、**完整的**按 spec 的数据日志、生效配置、下一步）。只列出**本 spec** 的 checkpoint（同一容器中其他 spec 的只计数、不列举），并概括项目级的变更允许列表而不是整段粘贴。 |
| `odoo_config` | 读取或更新持久化配置，并回答**"我现在在哪个项目里？"**：解析出的根目录、它的来源（会话 cwd / 已配置 / 进程 cwd）、specs 基础目录、生效的 spec 目录和正在使用的配置文件。 |
| `odoo_i18n` | 使用 **Odoo 自身的模型** 处理翻译：`base.language.export` 提供可翻译术语，`base.language.install` 激活语言。`export` 写入 `<模块>/i18n/<lang>.po`（缺失时创建 `i18n/` 目录）并与已有文件**合并** —— 已写好的译文绝不会被导出的内容替换，新术语会被追加，模块已删除的术语保留为 `#~` 而不是删除。`status` 报告每种语言的覆盖率，`check` 在仍有条目缺少文本、或缺少 OCA `oca-checks-po` 要求的 `#. module: <名称>` 注释时失败（导出会写入该注释，Odoo 自身的 POT 不会）。它不翻译：导出给出消息 ID，文本由人来写。感知版本（Odoo 16 是分界，也正是移除 `ir.translation` 的版本）。 |
| `odoo_import` | 通过 Odoo **自己的**导入器（`base_import`）准备 CSV/XLS/XLSX 导入，绝不使用本插件自己的解析器：`prepare` 用 web 会话和它自己的审批上传被授权的文件，`preview` 报告 Odoo 读到了什么（工作表、表头、有界样本、可导入字段），`map` 为每一列记录一个决定，`plan` 把它变成一个 `apply` 批次 —— 再由 `odoo_functional` 像其他批次一样批准并执行，所以这个工具自己永远不会应用导入。JSONP 应答会作为数据解析（绝不执行），会话 cookie 永不离开插件，不在已验证版本族内的版本会被拒绝并告知该调查什么。 |
| `odoo_functional` | 功能路径的批次执行器：`plan`（以 fail-closed 方式校验并存储一个批次）、`approve`（绑定 spec、设计、计划和批次哈希的原生人工审批）、`apply`（一次一个操作地执行，在调用前后持久化每个状态，并且**只有在回读该操作声明的状态之后**才记为已应用 —— 一个返回了应答却没有改变记录状态的调用是失败，不是成功）、`inspect`（在其自身范围内的只读发现）、`status`、`reconcile`（裁决返回为未知的结果）、`verify`（按验收标准给出证据）和 `compensate`（从日志构建撤销批次）。声明的环境会为运行设门禁，生产环境还需要一份声明的备份；批次运行期间，所有其他变更路径都会被拒绝。 |

---

## 🎛️ 委派模式

流水线一开始会问要委派多少 —— 每个项目用
`odoo_setup mode=autonomy decision=...` 记录一次：

| 模式 | 谁回答门禁 | 如何结束 |
|---|---|---|
| **Supervised**（默认） | 你，在每个受门禁控制的阶段 | 你批准，或者运行停下来等待 |
| **Autonomous** | 一个**人工代理** agent（`resources/roles/human-proxy.md`），只发出 fail-closed 的行首 `APPROVED` 或 `NEEDS_REVISION` | `create_goal` 无人值守地跑一轮轮迭代，直到 `DONE` 或 `BLOCKED` |

> [!TIP]
> 在自主模式下刹车依然上膛：`stop.md`、迭代上限和诊断阶梯都还在，而 `BLOCKED`
> 是运行唯一会呼叫人的方式。连接授权**不**受这个开关覆盖 —— 实例仍然需要由人
> 授权一次。

---

## 🧠 上下文工程分层

| 层 | 组件 |
|---|---|
| **identity** | `resources/roles/*.md` —— 架构师、开发者、qa、顾问、human-proxy、security-reviewer、文档等人设，带角色 + 边界。两个内置 skill 都把 `resources/` 声明为 `resourceBase`，因此每条 `roles/…` 与 `references/…` 路径都从宿主交给模型的那个基目录解析 |
| **odoo_connection** | `odoo-client.ts` —— JSON-RPC 认证、`execute_kw`、会话铸造 |
| **executors** | `odoo_module`、`odoo_execute`、`odoo_validate`、`odoo_errors` |
| **schemas** | 带必需章节的分阶段模板；`transition()` 会拒绝交付物缺少这些章节的阶段 |
| **knowledge** | 按版本固定的 Odoo 模式 skill（委派出去，由 skill 自身校验） |
| **skills** | `SKILL.md` —— 5 阶段编排流程，在 `apply()` 时自动注册到宿主 |
| **logbook** | `kb.json` —— 决策、被弃用的选项、blockers；提出方案前先读它 |
| **audit** | `.sdd/audit.jsonl` —— 经过脱敏的追加式工具活动日志，由全局 `tools/result` 监听器写入：本插件的工具与所有策略拒绝始终记录；开启 `auditAllTools` 时再记录其他每一次工具调用。写文件的工具按 路径 + 字节数 + sha256 记录，日志在 16 MB 时轮转并保留一代 |
| **rollback** | `.sdd/checkpoints/<id>/` —— manifest + 文件快照 + 数据日志，可按 spec 恢复 |
| **security** | `odoo_security_scan` 规则 + `security-reviewer` 人设 + CLARIFY 中强制的安全访谈 |
| **test** | `tests/smoke.mjs` —— 无需实例的不变量套件（状态机、安全、策略守卫、RPC 形态、根/specs 布局、真实 Cordis 宿主契约）+ `tests/client.mjs` —— 浏览器 bundle 契约和设置面板渲染 |

---

## 🛡️ 安全性、回滚与可追溯性

流水线假设 agent 迟早会出错，所以每一条变更路径都有回退方式，也有证明发生过什么
的方式。

- **有凭据不等于有授权。** 在任何工具向实例打开 socket 之前，必须已经有一个
  **人**批准过那个确切的目标。`odoo_setup mode=authorize` 通过宿主的原生审批
  通道发问，只有 `allowed-once` 结果才会在 `.sdd/grants.json`（0600，被
  gitignore）里存下回执。回执绑定 `url + db + username` 的指纹，所以改动其中
  任何一个都会让它失效；`mode=revoke` 会删除它。没有有效回执时，根本不会发出
  任何客户端，因此一个配置好的 `.env` 不可能被悄悄使用。在 AUTONOMOUS 模式下
  没有回答者，所以运行会报告 `NOT AUTHORIZED` 并挂起 —— 这正是重点。
- **模型无法放松自己的策略。** 修改白名单或策略守卫（`odoo_config mode=set`）
  以及切换委派模式（`odoo_setup mode=autonomy`），每一项都需要原生审批。
- **没有 spec，就没有变更 —— 除非你显式 waive。** 无论请求是什么，哪怕它就是
  同一个对话里的"顺便把 X 也改了"，一次变更都需要处于写作阶段的 spec
  （`WRITE_CODE`、`VERIFY`、`FIX_LOOP`、`APPLY_CONFIG`），或者一个你为*本次*
  会话批准的 waiver。这道门禁覆盖两个面：实例变更（`odoo_execute`、
  `odoo_module`、`odoo_import prepare`）以及通过宿主 `write`/`edit` 工具做的源码
  编辑。写 spec 文档本身（`specs/`、`.sdd/`）永远不会被挡 —— 否则那条出路也会被
  挡住。小改动就是小 spec：`mode=bug`、一条验收标准、没有设计访谈。如果你不想
  为它写 spec，也可以用 `sdd_phase
  operation=waive detail="..."`：它会把你的话记录下来、需要你的审批，并且只覆盖
  本次会话：下一次对话会重新回到这条策略之下，而在它之下做的每一项变更仍然会
  作为决策记入 KB。`requireSpecForChanges: false` 是人的关闭开关。
  **诚实的漏洞：** 守卫看到的是宿主工具调用，所以通过 `bash` 做的变更
  （heredoc、`sed -i`）不受门禁 —— 流水线是路径，不是监狱。

  **在哪里询问**（`specPolicyScope`，默认 `"odoo"`）。要求 spec 只在有 Odoo 工作的
  地方才有意义，而守卫过去只检查一件事：路径是否落在会话根目录内。因此按配置文件级别
  安装的插件会封锁与 Odoo 毫无关系的文件夹里的写入。使用 `"odoo"` 时，**文件编辑**只在
  目录显示 Odoo 工作迹象时才受门禁：

  - `.sdd/` 中有真正的插件状态（config、specs、checkpoints、waiver）—— **不是**只含
    `audit.jsonl` 的 `.sdd/`，审计监听器会为*每一次*工具调用写入它，因此它只意味着
    "这里跑过东西"，而不是"这是 Odoo 项目"；
  - 根目录有 `__manifest__.py` —— 该目录就是一个模块；
  - 下一层有 `__manifest__.py` —— 该目录包含模块；
  - 本会话中已对该根目录使用过 `odoo_*` 工具。

  **实例变更始终受门禁**：它本身就是 `odoo_*` 调用，也就是证据本身。`stop.md` 仍然
  停止一切，无论是否已采用，因为它是紧急停止而非范围问题。`"everywhere"` 恢复旧的
  全面行为，而拒绝信息现在会**指出触发策略的信号**，不再让你猜测。

  **三条出路，三条都可用。**（1）为本次变更开一个小的 spec，（2）继续已覆盖它的
  spec，（3）请开发者豁免 —— `sdd_phase operation=waive detail="..."`。第三条过去要求
  `spec_id`，这使它在唯一需要它的场景中不可用：没有 spec 的目录，你必须先创建一个
  spec 才能说你不想要 spec。豁免是按项目和会话的，现在 `spec_id` 对它（以及
  `waive revoke=true`）是可选的，而其他所有操作仍然要求它。
- **变更前先 checkpoint。** 在 `requireCheckpointBeforeMutation` 开启时（默认），
  `odoo_execute` 的变更会被拒绝，直到 `sdd_checkpoint create` 已对活动 spec 做过
  快照 —— 并且在 `WRITE_CODE` 之前直接被拒绝。快照会跳过符号链接（`lstat`），
  也绝不复制 `.env` 或密钥材料。
- **fail-closed 守卫。** 内部守卫失败时会带一个可见的理由拒绝，而不是放行调用。
- **文件回滚。** `sdd_checkpoint restore` 把快照中的文件逐字节放回；
  `sdd_phase rollback` 把 spec 退回 `WRITE_CODE` 并记录失败，让循环从一个已知
  状态重新开始。checkpoint 快照的是项目树，所以在 **central** specs 布局下，
  spec 文档（它们位于项目之外）故意不属于快照的一部分：spec 是不可变的真相
  来源，不是要回滚的代码。
- **数据回滚（尽力而为，并且对此诚实）。** 每一次通过 `odoo_execute` 的
  `create`/`write`/`unlink` 都会把它的前像记录进 checkpoint 日志，并盖上数据库
  **以及**它所应用到的目标（url+db+user）的戳；`restore restore_data=true
  confirm_destructive=true` 会反向重放，并拒绝来自其他目标的日志。重放在该
  变更用过的公司上下文中运行，把读取形态转换成写入值（many2one、x2many），在
  补偿每个操作时标记它，使重试不会重复其中任何一个，并报告它无法恢复的字段
  （二进制内容、只读或非存储字段）。被重新创建的记录会拿到**新的** id ——
  报告会说明这一点。它覆盖的是通过插件写入的数据 —— **不**包括模块安装/升级的
  副作用，那些不会在数据库层面被还原。
- **restore 会报告漂移。** `restore` 总会列出 checkpoint *之后*创建的文件，
  所以不会有东西被悄悄留下；`remove_created=true` 会删除它们（仅在快照的根之内）
  以与快照完全一致。
- **文档是门禁，不是脚注。** ARCHITECTURE 把决定记录在 `## Documentation`，
  WRITE_CODE 产出 OCA 片段 + Apps 的 `index.html` + 强制的 changelog 条目，
  而 `DONE` 由 `documentationPolicy` 把关（默认 `required`；另有 `optional` 和
  `off`）。插件做不到的事，它会直说：`gen-odoo-readme`、`towncrier`、Ruff 和
  pylint 需要 shell，所以片段才是真相来源，编译 `README.rst` 仍然是你的步骤。
- **生命周期：插件拥有自己的状态，也能把它交还。** `odoo_setup mode=purge`
  会打印一份计划（它拥有什么、它有意保留什么），并且只有在
  `confirm_destructive=true` 加原生人工批准之后才删除。它绝不碰 `.env`
  （你的凭据）、`stop.md`（你的刹车）或 `specs/`（你的文档）。
- **持久化状态。** 流水线状态、KB、结论、授权和日志都用原子替换写入，损坏的
  文件会被隔离在原文件旁边而不是被覆盖：`sdd_phase status` 会报告这次恢复。
- **可追溯性。** `.sdd/audit.jsonl` 记录每一次调用的结果
  （`ok` / `error` / `denied`）、耗时和阶段；`sdd_phase status` 打印 logbook；
  `sdd_handoff` 把整次运行冻结进 `handoff.md`。
- **紧急刹车。** `stop.md`（位于 `.sdd/stop.md` 或 `specs/<active>/stop.md`）
  会中止所有工具；迭代上限和诊断阶梯会把运行导向 `BLOCKED`，而不是无限循环。

---

## 🔐 安全与网络策略

- **不回传任何数据。** 插件**唯一**的外发网络调用发往你写在 `.env` 里的实例
  URL。没有遥测，没有更新检查，没有第三方端点。
- **传输守卫（fail-closed）。** `http://` 只对回环主机（`localhost`、`127.x`、
  `::1`、`*.localhost`）接受；其他任何目标都必须是 `https://`，否则凭据加载
  会被拒绝 —— 对远程主机用明文 http 会把 API key 明文发出去。
- **密钥隔离。** 密钥只由 `credentials.ts` 读取一次，并且只注入到 RPC 参数里。
  每一份工具输出在展示给模型或持久化进 KB 之前，都会经过两层脱敏（已知密钥 +
  通用的 `password=` / `Bearer` / `api_key=` / `session_id` 形态）以及家目录路径
  掩码（`/home/user/…` → `~/…`）。
- **会话 cookie 永远不会到达模型。** `odoo_session` 把 cookie 写入
  `.sdd/session.json`（chmod 600），只返回路径。截图引擎甚至不写 cookie：它在内存中
  认证 web 会话，并把 cookie 留在浏览器上下文里。
- **截图浏览器被锁定在实例源上。** 任何离开实例基础 URL 的请求都会被中止，因此试图访问
  第三方主机的页面会失败而不是加载。截图默认只读：只做观察的步骤（`goto`、`menu`、
  `wait`、`assert`、`explore`、`screenshot`、`confirm`）总是执行；会动手的步骤
  （`new`、`fill`、`select`、`press`、`click`）需要 `allow_actions=true` **和**
  `confirm_destructive=true`，守卫会把这次调用归类为一次变更（spec 处于写作阶段，或者
  有 waiver）—— 而**一次点击没有可补偿的条目**：与 CRUD 调用不同，插件无法撤销 UI 做过
  的事。请针对可丢弃的数据库做截图。
- **录制只作为数据读取，凭据会被拒绝。** `odoo_manual operation=import` 解析 `codegen`
  文本；携带密码字段、`Authorization` 头或 cookie 的录制会按行被拒绝，而且那个值绝不会
  被回显出来。
- **在 git 还能看到它时，凭据文件不会被写入（fail-closed）。** 在脚手架出一个 `.env`
  之前，插件会去问 git 自己 —— 忽略规则问 `git check-ignore`，索引问 `git ls-files` ——
  当受保护的路径不在 `.gitignore` 里**或已经被跟踪**时，就以 `status: needs-gitignore`
  拒绝，在后一种情况下它给出的唯一修法是 `git rm --cached`。忽略规则永远保护不了已经
  进入索引的文件，而这个缺口正是一个带着有效密码的 `.sdd/.env` 曾经到达远程分支的原因。
  在 git 缺席的地方，回退方案会读取 `.gitignore` 文本并说明这一点，而不是声称已证明。
  可用 `requireGitignoreBeforeCredentials: false` 按项目关掉。
- **构建之外没有安装脚本。** 唯一的生命周期脚本是 `prepare`/`prepack`，它们把
  `src/` 编译成随包发布的 `lib/`，别的什么都不做 —— 没有网络，没有
  `postinstall`，没有 shell。两者的失败策略故意相反：**`prepare`（在安装时运行）
  从不弄挂一次安装** —— 没有 devDependencies 或没有可选宿主 peers 时，它会说明
  自己无法检查什么，并在不做类型检查的情况下产出，好让插件仍能加载 —— 而
  **`prepack`（在发布时运行）拒绝打包一个它无法通过类型检查的构建**。`prepack`
  同时保证已发布的 tarball 绝不会缺少它 `main` 所承诺的入口点（那个失败真实
  发生过：一个干净的 clone 打包了 39 个文件，其中 `lib/` 下零个；它的续集也
  是真的：一个在 `npm install` 期间做类型检查的 `prepare`，在安装那些 peers 的
  步骤能运行之前就让每个 CI job 都失败了）。
- **宿主要求声明了两次**，遵循 dsh-market 的发现约定：`engines.dsh` 以及在
  `@deepseek-ai/{cordis,dsh-tools,schemastery}` 上 lockstep 的可选 peer 范围。
  在没有 `tools` 服务的宿主上，插件会带一个显式错误拒绝挂载，而不是带着故障启动。

---

## 🖥️ 平台支持

DSH 能跑的地方插件就能跑，并声称支持 **Linux、macOS 和 Windows** —— 这个声明
由 CI 矩阵来检验而不是断言（`ubuntu-latest` **和** `windows-latest`，Node 20、
22 和 24）。

| 关注点 | 行为 |
|---|---|
| 路径 | `module_dir` 和显式根目录同时接受 POSIX（`/opt/odoo`）和 Windows（`C:\odoo`、UNC）写法；绝对路径绝不会被拼接到项目根目录之下。 |
| `.env` 权限 | 会请求仅属主可读（0600），并在 `chmod` **之后重新检查**：在无法表达权限位的文件系统上（Windows、FAT/exFAT、某些挂载），插件会说"已请求仅属主模式"并追加一条说明，而不是假装文件是私有的。在真正的 POSIX 文件系统上，无法收紧的宽松权限仍然会被拒绝。 |
| 原子写入 | 状态文件先写到同级的临时文件再改名就位，对 `EPERM`/`EACCES`/`EBUSY` 做有界退避重试 —— 这正是 Windows 因为编辑器、索引器或杀毒软件持有打开句柄而拒绝改名的情况。 |
| 项目根目录 | 每次调用都从**会话所在文件夹**解析；进程 cwd 只是最后手段，并会被报告为 `LAST RESORT`。 |
| 符号链接 | checkpoint 绝不跟随符号链接离开项目树；在操作系统或用户权限不允许创建符号链接的地方，测试套件会跳过它的符号链接断言 —— 并且说明这一点，而不是悄悄通过。 |

### 📁 spec 存放在哪里

项目根目录就是当前会话中打开的文件夹，所以它不是插件级的设置。spec 文档跟着它走：

| 布局 | 路径 | 何时选它 |
|---|---|---|
| **project**（默认） | `<projectRoot>/<specsDir>/<specId>` | spec 应该随代码一起走 |
| **central** | `<specsRoot>/<projectSlug>/<specId>` | 你维护很多模块仓库，想要一个可搜索的统一位置 |

在 central 布局下，每个项目都有自己的子文件夹，带一个 `.dsh-project-root` 标记；
如果两个项目共用同一个目录名，还会加一个哈希后缀 —— 外来的文件夹绝不会被收养。
`.sdd/` 始终留在项目里。

```text
<projectRoot>/
├── .sdd/                       # 插件自有，已 gitignore
│   ├── .env                    # 凭据（chmod 600）
│   ├── config.json             # 项目配置
│   ├── grants.json             # 人工授权回执
│   ├── instances/<name>.env    # 每个被命名目标的凭据（chmod 600）；一个是激活的
│   ├── session.json            # Playwright cookie
│   ├── audit.jsonl             # 工具活动，已脱敏（文件内容存为摘要）
│   ├── setup-state.json        # 上手引导 + 委派决定
│   ├── active/<sessionId>.json # 本会话的指针（spec、阶段、checkpoint）
│   ├── active.json             # 项目最近使用的 spec（参考用；见 adoptProjectPointerHint）
│   └── checkpoints/<id>/       # manifest + 文件快照 + 数据日志
└── specs/<NNN>-<slug>/         # 或者 central 文件夹
```

**spec 指针属于「会话」，不属于项目。** 每个对话写入自己的
`.sdd/active/<sessionId>.json`，因此同一个项目上打开的两个会话不再互相覆盖，
也无法用对方的 spec 来授权改动。`.sdd/active.json` 仅保留为*项目最近使用的
spec*：没有自己指针的会话，在它足够新（< 12 小时）且该 spec 仍然存在时，会把它
当作**提示**采用。若要严格范围 —— 每个会话在声明之前都没有 spec
（`sdd_phase status spec_id=…`）—— 请在面板或 `odoo_config` 中设置
**`adoptProjectPointerHint: false`**。checkpoint 的*快照*仍属于项目；而哪个是
你的回滚目标是每个会话各自的，删除时会清除所有指向它的指针引用。

> [!TIP]
> 同时打开多个项目时，读一读每个工具结果都带的那行
> `Project root: … [provenance]`，或者问 `odoo_config mode=read` —— 它会返回
> 解析出的根目录、它的来源和生效的 spec 目录。

---

## ⚙️ 配置

在 Web UI 中打开 **Settings → Odoo SDD**。一切都可以在那里编辑，另外在你需要的
地方还提供了几个可复制粘贴的预设。

> **宿主要求：**插件只在 **dsh >= 0.2.2-rc.1** 上挂载（即 `engines.dsh`，宿主唯一强制校验的
> 版本字段）。设置面板需要 **dsh >= 0.2.0-rc.1**：该版本移除了面板原先绑定的
> `settingsScope` 服务，改为根据插件自身的 `Config` schema 生成表单（条目 `odoo-sdd`）。
> 在更旧的宿主上该分区不会出现，插件会在控制台说明原因；工具与流水线照常工作，
> 可用 `odoo_config mode=read|set` 在对话中编辑同样的值。


<p align="center">
  <img src="assets/settings-panel.jpg" alt="Settings → Odoo SDD：spec 存放在哪里、谁批准各个阶段、许可策略，以及变更白名单" width="640" />
</p>

```yaml
# ~/.dsh/profiles/<profile>/cordis.patch.yml（可选：同样的字段，作为补丁）
- insert:
    - id: odoo-sdd
      config:
        specsMode: project      # project | central
        specsRoot: ''           # specsMode=central 时的绝对文件夹
        specsDir: specs         # specsMode=project 时项目内的文件夹
        executeAllowlist: []    # odoo_execute 可以 create/write/unlink 的模型
        methodAllowlist: []     # 功能批次可调用的 "model.method" 对（任意模型）
                                # 即席 RPC 不需要列表：任意公共方法 + confirm_destructive
        communityRepoUrl: https://github.com/odoo/odoo
        enterpriseRepoUrl: https://github.com/odoo/enterprise
        communityRepoPath: ''   # 本地 OS 路径；设置后会**覆盖** communityRepoUrl
        enterpriseRepoPath: ''  # 企业版同理
        odooApi: auto           # auto | json2 | jsonrpc（auto 在 401 时回退）
        autonomy: supervised    # supervised | autonomous
        licensed: community     # community | enterprise（OCA 总会被搜索）
        requireCheckpointBeforeMutation: true
        requireSpecForChanges: true     # 每一项变更都需要处于写作阶段的 spec（或 waiver）
        specPolicyScope: odoo           # odoo（仅在检测到 Odoo 工作时）| everywhere
        securityReviewRequired: true
        securityInterviewRequired: true
        auditAllTools: false    # 额外记录其他每一次工具调用（文件内容存为摘要）
        maxCheckpoints: 5
        documentationPolicy: required   # required | optional | off
        documentationLanguage: ''       # 留空 = 英文，除非项目另有规定
        manualsMode: specs              # specs（spec 同目录）| docs（在 manualsDir 下）| module（readme/manual/）
        manualsDir: docs/manuals        # 仅当 manualsMode 为 docs 时使用
        videosDir: docs/videos          # odoo_video 写入 <slug>/ 工作区的位置（项目根目录内）
        ttsCommand: ''                  # 仅配音视频。外部 TTS，无 shell；占位符
                                        #   {text} {textFile} {voice} {out}。示例：
                                        #   edge-tts（云端）: edge-tts --voice {voice} --text {text} --write-media {out}
                                        #   piper （离线）: piper --model {voice} --output_file {out}   （用 {textFile}）
        ttsVoice: ''                    # 作为 {voice} 传入；示例语音: es-ES-ÁlvaroNeural（edge）/ es_ES-sharvard-medium（piper）
        ffmpegPath: ffmpeg              # 视频装配（系统 ffmpeg，4.x+；已验证 7.x），ffprobe 在其旁边
```

> [!IMPORTANT]
> 有两个配置存储，更具体的那一个获胜：**Settings**（用户级，
> `~/.dsh/settings.yaml`）和项目的 **`.sdd/config.json`**（由
> `odoo_config mode=set` 按项目写入）。如果某个键在面板里改过之后看起来被忽略
> 了，那就是项目文件把它钉住了 —— `odoo_config mode=read` 会报告生效值。

---

## 🤖 模型体验

agent 看到 19 个自包含描述的工具。典型流程：
`sdd_phase init` → 安全访谈 + `odoo_connect` → 带 `APPROVED` 的门禁阶段 →
`sdd_checkpoint create` → 写代码 → `odoo_security_scan` →
`odoo_module install` → 出现 traceback 时用 `odoo_errors` + `sdd_phase fail`
（它可能强制一次诊断）→ 修复（或 `sdd_phase rollback`）→ 重新验证 →
`sdd_phase succeed` → `sdd_handoff` → `DONE`。工具响应是可执行的文本：服务器
traceback、门禁拒绝理由和补救说明。

工作流 skill 在挂载时注册，所以它的**名称和描述**会自动出现在每个会话的 skill
目录中。完整指令在模型选中它时加载（目录里只有摘要），或者在你输入
`/odoo-sdd-workflow` 时加载。

---

## ⚠️ 已知限制与待办工作

- **远程测试** —— 没有实例的 shell 访问就无法运行 `--test-enable`；第二层验证是
  RPC/UI 测试。待办：如果你暴露测试运行器，就提供一个可选的 `odoo_run_tests`
  工具。
- **数据回滚是尽力而为** —— 测试会写入所连接的数据库，而且没有临时克隆
  （这是设计决定：目标由你提供并归你所有）。`sdd_checkpoint` 能撤销通过
  `odoo_execute` 写入的数据，但模块安装/升级**不会**在数据库层面被还原。请使用
  可丢弃的数据库。
- **静态安全扫描的范围** —— `odoo_security_scan` 是基于规则的源码文本检查
  （没有 AST，没有污点追踪），所以它能抓住常见的 Odoo 错误，但不是全部；它补充
  人工审查，绝不替代人工审查。
- **变更策略管的是工具调用，不是 shell** —— `write`/`edit` 和 Odoo 工具会受门禁
  约束；而通过 `bash` 写入的文件（heredoc、`sed -i`）不会，因为宿主不会事后检查
  一条 shell 命令写了什么。这条策略让流水线成为阻力最小的路径、让例外保持可见；
  它不是沙箱。
- **多实例** —— 每个项目一个目标（`.env`）。待办：具名实例配置
  （`dev`、`staging`）。
- **面板中仅作信息的字段** —— `autonomy` 和 `securityInterviewRequired` 会被
  存储和报告，但流水线从 `.sdd/setup-state.json` 读取委派决定（用
  `odoo_setup mode=autonomy` 设置），并通过 ARCHITECTURE 的内容门禁强制执行安全
  访谈。已记为待办工作。
- **手动截图需要浏览器和密码** —— `playwright-core` 是可选依赖，浏览器本身并不随包发布：
  没有 Chromium、Chrome 或 Edge 时，截图会返回 `NEEDS_BROWSER` 以及那行安装命令。web 会话
  还需要账号**密码**（Odoo 在交互式登录中拒绝 API key），所以一个只有 API key 的项目仍然
  能编写和 lint 一份手册，但无法自动驱动截图。
- **手册一次只覆盖一个模型** —— canon 是按模型读取的，所以一次跨模型的走查就是针对多份
  canon 规划出的多条流程，而分镜会保留它规划时所用 canon 的指纹（实例变动时
  `CANON-STALE` 会警告）。
- **UI 家族 15、16、18、19 是声明，不是已验证** —— 截图选择器在 17 上做过端到端验证；
  其他家族共享同样的结构，但在你依赖它们之前必须对着一个 dev 实例检查，而且 `familyFor`
  会拒绝一个它不认识的 major，而不是去猜。
- **散文 lint 是建议性的** —— AI 腔规则是启发式的，在 v1 里每一条都被设计成 WARN：只有
  结构性规则（每个被截图的步骤一句话，过程内不出现标题）和 canon 规则能够阻断。
- **没有富 UI 渲染器** —— 工具输出就是 DSH web GUI 里的文本。

---

## ❓ 故障排查

| 现象 | 含义 | 怎么做 |
|---|---|---|
| `NOT CONFIGURED` | 级联中没有可用的 `.env` | `odoo_setup mode=interactive` |
| `NEEDS_SECRET` | 脚手架存在，但 `ODOO_PASSWORD` 为空 | 在文件里填写，绝不在聊天里填 |
| `NOT AUTHORIZED` | 凭据存在，但没有针对该目标的有效人工授权 | `odoo_setup mode=authorize` |
| `Instance unreachable` | 版本探测失败 | 检查 URL/端口，以及实例是否在运行 |
| 变更总是被拒绝 | 没有 checkpoint，或者 spec 还没到 `WRITE_CODE` | 批准门禁，然后 `sdd_checkpoint create` |
| 一切都停了 | `stop.md` 存在 | 读它，然后删除它 |
| 面板改动似乎被忽略 | 项目的 `.sdd/config.json` 优先级高于全局 Settings 层 | `odoo_config mode=read` 显示生效值 |
| 某个 spec 目录"找不到" | 你在另一个项目文件夹里 | 在会话中打开那个项目的文件夹 |
| `odoo_manual capture` 返回 `NEEDS_BROWSER` | 找不到 Chromium/Chrome/Edge | `npx playwright-core install chromium`，或者传 `browser: "chrome"` |
| `odoo_video voice` 返回 `NEEDS_TTS` | 未配置 `ttsCommand`（或模板需要 `{voice}` 而未设置语音） | 配置 `ttsCommand`（消息里带按语言的示例语音：离线 piper-tts、云端 edge-tts）和 `ttsVoice` |
| `odoo_video assemble` 返回 `NEEDS_FFMPEG` | 系统 `ffmpeg`（配置 `ffmpegPath`）缺失或无法运行 | 安装 ffmpeg 4.x+（已验证 7.x），或把 `ffmpegPath` 指向其绝对路径 |
| `odoo_video assemble` 返回 `NEEDS_VIDEO_CAPTURE` | 该流程没有带视频条目的 `capture.json`（截图时未录制、是另一条流程、或从未跑过） | 先运行 `odoo_video operation=capture flow=<id>` —— 装配总是读取录制的 webm |
| `odoo_video capture` 返回 `no view rendered within 30s` | 该 URL 不是 webclient 动作，或浏览器无法访问该实例 | 先用 `odoo_manual operation=explore` 打开该动作一次，或修正 target 的 xmlid；截图会等待视图及其记录，绝不仅等待 `load` |
| 视频以空白画面开场 | 播放的是此检查之前拍摄的截图，或手工裁切的 webm | 重新截图：装配只保留每个 `goto` 窗口的尾部，并把前导偏移记录在 `video/video.json` 的 `captureOffsetSeconds` 中 |
| 视频截图要求 Playwright 的 ffmpeg | 录制使用 Playwright **自带**的 ffmpeg，是与 chromium 分开的一次下载 | 运行一次 `npx playwright install ffmpeg`（或 `npx playwright-core install ffmpeg`） |
| `odoo_manual capture` 返回 `NEEDS_WEB_SESSION` | 目标只有 API key，而 Odoo 在交互式登录中拒绝它 | 在 `.env` 里放一个账号密码，或者用 `odoo_session` 创建 `.sdd/session.json` |
| 截下来的屏幕语言不对 | 浏览器跟随登录用户的语言，而插件从不写 `res.users` | 用一个语言与手册一致的用户来截图 |
| `allow_actions=true` 被拒绝 | 守卫把它归类为一次变更 | 把 spec 推进到写作阶段，或者登记一份 waiver —— 并使用可丢弃的数据库 |
| 截图进行到一半就停了 | 第一个失败的步骤会中止运行并写下它已经拿到的内容 | 修好分镜，然后用 `from_step: "<失败步骤的 id>"` 重跑 |
| `odoo_docs` 在没有片段缺失时仍报 `NEEDS_CONTENT` | 一条手册 ERROR（截图缺少对应图片、canon 已过期），或者有骨架还留在原地 | 读报告里的 `## User manual` 一节 |
| 安装时报 `dsh: pnpm was not found` | `dsh plugin` 依赖 pnpm，而 pnpm 并不随 Node 分发 | 先执行 `npm i -g pnpm`（Node 25 已移除 `corepack`），再重试 `dsh plugin add` |
| 报 `Unsupported engine` 或 `plugin … is not compatible` | 宿主只校验 `engines.dsh`：低于 **0.2.2-rc.1** 的 DSH 会被拒绝；pnpm 还会校验该宿主自带的 `node >= 24.9.0` | 升级 DSH（以及 Node）—— 插件不会运行在未经测试的宿主上 |
| `npm rebuild` 报 `ERR_MODULE_NOT_FOUND … scripts/prepare.mjs` | 0.19.0 之前打包的 tarball 未包含构建钩子，而 `npm rebuild` 会执行 `prepare` | 重新安装该包（钩子现已随包发布，且在 `lib/` 已编译时为空操作） |

---

## 🧩 实现细节

<details>
<summary>插件形态、源码地图与安全决策 —— 点击展开</summary>

### 插件形态

遵循 DSH 工具插件约定（`dsh-tool-todo`、`dsh-tool-goal`）：具名导出 `name`、
`inject`、`Config`（schemastery schema）和 `apply(ctx, config)`，并用
`@deepseek-ai/dsh-tools` 的 `defineTool` 注册每个工具。浏览器那一半是一个普通的
JS ModuleLoader bundle，它贡献 **Odoo SDD** 设置区块。

### 源码地图

| 文件 | 作用 |
|---|---|
| `src/index.ts` | 插件入口：注册 19 个工具、解析配置和策略守卫 |
| `src/types.ts` | 公开的 payload 类型（绝不包含密钥材料） |
| `src/credentials.ts` | 凭据级联、`.env` 加载/校验、权限验证、`redact()`、fail-closed |
| `src/odoo-client.ts` | JSON-RPC 客户端：`common.version`、`authenticate`、`execute_kw`、`button_immediate_*`、`ir.logging`、`/web/session/authenticate` |
| `src/tools-runtime.ts` | 面向 Odoo 的工具主体：`odoo_execute`（白名单 + 前像捕获）、`odoo_validate`、`odoo_module`、`odoo_errors` |
| `src/sdd-state.ts` | 阶段状态机、门禁、追加式 KB、结论、安全内容门禁、`stop.md` |
| `src/checkpoints.ts` | checkpoint 存储：manifest、文件快照/恢复、数据日志、清理预算 |
| `src/security-scan.ts` | 无需实例的静态安全规则（`scanModule`），发现项带 `file:line` |
| `src/audit.ts` | 已脱敏的追加式审计日志（`.sdd/audit.jsonl`）和 `withAudit` 包装器 |
| `src/setup-state.ts` | 上手引导决定 + 委派模式的持久化（`.sdd/setup-state.json`） |
| `src/grants.ts` | 人工授权回执（`.sdd/grants.json`），绑定指纹且 fail-closed |
| `src/atomic.ts` | 原子写入（对 `EPERM`/`EACCES`/`EBUSY` 做有界重试）以及损坏隔离 + 恢复报告 |
| `src/paths.ts` | 跨平台的 `module_dir` 解析 |
| `src/specs-location.ts` | spec 存放位置：project 与 central 布局、带来源的会话根解析、slug/标记/冲突处理 |
| `src/lifecycle.ts` | 归属清单和 `purge` 原语（只处理自有状态；绝不碰 `.env`/`stop.md`/`specs/`） |
| `src/docs-scan.ts` | 文档规则：OCA 片段 + Diátaxis、版本方案、changelog、index.html、docstring、xpath、OWL |
| `src/docs-tool.ts` | `odoo_docs` 工具（check/plan/scaffold/report），无需流水线即可使用 |
| `src/test-scan.ts` | 模块 Python 测试的静态规则（未被导入的测试模块、用例类、断言密度、被吞掉的错误、`cr.commit`、sleep、跳过、按版本区分的 `SavepointCase`） |
| `src/tests-tool.ts` | `odoo_tests` 工具（`check`/`plan`/`scaffold`），无需流水线即可使用 |
| `src/manual-model.ts` | 手册工作区解析（3 种模式，带包含性检查）、带稳定 id 的分镜读写、从 canon 出计划、canon 规则校验、步骤安全分级 |
| `src/manual-canon.ts` | 功能 canon：XML arch 分词器（17 的 `invisible=` 表达式、14–16 的 `attrs`/`states`、statusbar 状态）、模块错误扫描、按版本区分的读取清单（≤15 用 `load_views`，16+ 用 `get_views`，19 中 `groups_id`→`group_ids`）以及一枚稳定的指纹 |
| `src/manual-tool.ts` | `odoo_manual` 工具：canon / plan / lint / render / explore / capture / import，带只创建策略和失败后恢复的路径 |
| `src/video-model.ts` | 视频工作区解析（`videosDir`，含包含性检查）、视频所需的轻量分镜校验（canon 过期、空流程、未知目标、静音步骤），以及 `from_manual` 导入（复制 canon + 分镜，绝不碰手册） |
| `src/video-voice.ts` | 配音：带引号的模板拆分、无 shell 的 TTS spawn、按步骤幂等的 `.wav`、带实测时长的 `voice.json`、`NEEDS_TTS` 补救用的按语言示例语音 |
| `src/video-assemble.ts` | ffmpeg 装配：按步骤剪辑 + 按配音时长定格（tpad）、concat demuxer、faststart 封装、总是交付的 SRT/VTT、可选烧录字幕、`video.json` 报告 |
| `src/video-tool.ts` | `odoo_video` 工具：plan（实例或 `from_manual`）/ voice / capture（录制始终开启，与手册相同的门禁）/ assemble |
| `src/prose-lint.ts` | 西语和英语的建议性散文规则（AI 腔模式、句子长度、可读性）以及 OCA 格式所依赖的两条结构性 ERROR 规则 |
| `src/manual-render.ts` | 确定性的 Markdown + Apps 页面渲染、逐文件 `sha256`，以及一个绝不会被覆盖（除非 `overwrite=true`）的手工编辑文件 |
| `src/manual-capture.ts` | 浏览器引擎：源锁定、只读优先的步骤门禁、带标注的 PNG（1440×900，标注层已移除）、ARIA 快照、`capture.json` |
| `src/manual-import.ts` | Playwright `codegen` 方言读取器：canon 标签映射、以 WARN 代替猜测的通用步骤、按行拒绝凭据 |
| `src/manual-audit.ts` | 只读审计，让文档结论能覆盖一份已有手册（截图覆盖率、canon 新旧、散文健康度），没有手册时不改动任何东西 |
| `src/project-conventions.ts` | 从项目自己的规则解析文档语言，默认英文 |

### 安全决策

- 密钥只存在于 `credentials.ts` 内部和 RPC 调用参数中；每一份输出都经过
  `redact()`（包括 `user:pass@` 形式的 URL）。
- 传输守卫在认证之前就拒绝既不是 HTTPS 也不是回环的任何目标。
- 变更是双重 fail-closed：白名单在每次调用时实时读取，而策略守卫会拒绝
  `create`/`write`/`unlink`，除非 checkpoint 存在且 spec 处于 `WRITE_CODE`
  或更后的阶段。
- 密钥永远不会作为工具参数被接受，永远不会写入审计日志，也永远不会通过聊天
  索要。
- 状态损坏 ⇒ 重启（进度绝不被伪造）；门禁含糊 ⇒ 拒绝；缺少验证 ⇒ 到不了
  `DONE`；安全缺口 ⇒ ARCHITECTURE 门禁被拒绝。

### 构建与测试

```bash
npm run typecheck   # tsc --noEmit
npm run build       # 生成 lib/（必需：包的 main 是 lib/index.js）
npm test            # 服务器不变量 + Cordis 宿主契约 + 客户端 bundle + README + functional/import
npm run test:package  # tarball 内容（运行时加载但 `files` 漏掉的文件）
```

这些正是 [CI](.github/workflows/ci.yml) 在 Linux 和 Windows 上运行的步骤，
所以在本机跑 `npm run typecheck && npm test` 就能复现这条流水线。

`test:package` 还会**模拟发布**：它复制一份不含 `lib/` 的包（也就是全新 clone
的样子），对它运行 `npm pack`，并断言 tarball 里仍然含有编译后的入口点。正是
这项检查让一个已发布的版本不会变成"装得上却加载不了"。接着它模拟**安装**那一侧
—— 一棵有编译器但没有可选宿主 peers 的树，而这正是 CI 的 `npm install` —— 并
断言在那里 `prepare` 以 0 退出，而 `prepack` 会拒绝。

### 发布（维护者）

**npm 不跟随 GitHub。** 它们是两个独立的 registry：一次 push、一个 tag 或一个
GitHub Release 更新的是 GitHub，别的什么都没有，而 `npm publish` 更新的是 npm，
别的什么都没有。已发布的版本是**不可变的** —— 它不能被覆盖，只能被超越 ——
所以每次发布都要提升 `package.json` 的版本号。

`lib/` 是构建产物且被 gitignore，所以 tarball 由 `prepack` 钩子构建 —— 绝不手工
构建，绝不来自陈旧的树。**第一次**发布是手动的（npm 只允许为一个已经存在的包注册
trusted publisher）：

```bash
npm login                 # 只需一次；之后 `npm whoami` 应该有回应
npm publish               # prepack 运行 `tsc` 并打包产物
npm view dsh-odoo-sdd version   # 核实 registry 上实际是什么版本
```

之后，
[`.github/workflows/publish.yml`](.github/workflows/publish.yml) 会接手：
**发布一个 GitHub Release**（或手动 *workflow_dispatch*）会运行与 CI 相同的门禁
—— typecheck、build、测试、发布模拟 —— 检查 release tag 与 `package.json` 一致，
拒绝一个已经在 registry 上的版本，并通过 npm 的 OIDC **trusted publishing** 以
**provenance** 发布，所以这个仓库里不存在任何 `NPM_TOKEN`。

那套自动化的一次性配置：在 npmjs.com → 该包 → *Settings* →
*Trusted publishers* → *Add* → provider 选 **GitHub Actions**，owner 填
`fhidalgodev`，repository 填 `dsh-odoo-sdd`，workflow 文件名填 `publish.yml`，
environment **留空**（不一致会得到 `403 npm-trusted-publisher-not-configured`）。
更想用 token？创建一个带 *bypass 2FA* 的 granular access token，并把它存成
`NPM_TOKEN` secret —— workflow 里写了存在哪里。

所以一次发布就是：提升版本号 → merge → 发布 Release，最终 tag、Release 和 npm
上的版本三者一致。

同样的钩子也让从仓库安装可用：`prepare` 在 TypeScript 存在时编译源码
（`npm i github:fhidalgodev/dsh-odoo-sdd`），不存在时带一条提示跳过
（`file:` 安装没有 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>

> 图表由 [star-history.com](https://star-history.com) API 实时生成。

## 🙏 致谢

这个插件为 Odoo 开发者社区而构建，它立足于三个生态：

- **[Odoo Community Association (OCA)](https://github.com/OCA)** —— 这条流水线
  所执行的编码规范、模块布局和质量门禁。
- **[DeepSeek Harness (DSH)](https://github.com/deepseek-ai)** —— 这个插件所
  运行的插件架构（Cordis tools/plugins、skills、subagents）。
- **[Playwright](https://playwright.dev)** —— `odoo_manual operation=capture`
  背后的浏览器引擎，以及导入所读取的 `codegen` 录制。

手册写作指南取材于已出版的文档实践，而不是另立一套自家风格：**Diátaxis**
（OCA readme 已经在用的片段象限）、**Information Mapping** 和 **John Carroll 的
极简主义**用于面向任务的过程，**ISO/IEC 26514** 用于用户文档的结构，开篇一幕则
采用 **StoryBrand / "lead with insight"** 这一派叙事框架。反 AI 腔的散文清单参考了
公开的 storytelling 与 content skill 合集（尤其是
[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) 和
[lyndonkl](https://github.com/lyndonkl/claude)）—— 留下来的规则是那些能抓住一个真实
小毛病、又不惩罚一句清楚句子的规则。

感谢每一位贡献模式、评审和想法来塑造 SDD 工作流的人。贡献者：

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

---

## 📜 许可证

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