Metadata-Version: 2.4
Name: diy-harness
Version: 0.1.0
Summary: diy-harness (dh)：自研 agent harness —— 运行时自己实现，instructions 沿用 Deep Agents
Author: zxu756
License-Expression: MIT
Project-URL: Homepage, https://github.com/zxu756/diy-harness
Project-URL: Repository, https://github.com/zxu756/diy-harness
Project-URL: Issues, https://github.com/zxu756/diy-harness/issues
Project-URL: Documentation, https://github.com/zxu756/diy-harness/blob/main/docs/overview.md
Project-URL: Changelog, https://github.com/zxu756/diy-harness/blob/main/TASKS.md
Keywords: agents,ai,llm,harness,deep-agents
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: <4.0,>=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: THIRD_PARTY_NOTICES.md
Requires-Dist: openai<4.0,>=3.19
Requires-Dist: typer<1.0,>=0.27
Requires-Dist: rich>=14.0
Requires-Dist: pydantic<3.0,>=2.13
Requires-Dist: wcmatch<12.0,>=11.0
Requires-Dist: prompt-toolkit>=3.0.53
Dynamic: license-file

# diy-harness（`dh`）

[![ci](https://github.com/zxu756/diy-harness/actions/workflows/ci.yml/badge.svg)](https://github.com/zxu756/diy-harness/actions/workflows/ci.yml)

自研 agent harness：**运行时自己实现**（agent loop、工具、后端、中间件），
**instructions 沿用 Deep Agents**（系统提示词、工具描述、中间件段落、子 agent 提示词、工作流）。

设计文档在 [`docs/overview.md`](https://github.com/zxu756/diy-harness/blob/main/docs/overview.md)。当前进度：**M0–M9 全部收口，
M10 把包发上 PyPI**（里程碑状态与证据见
[`docs/overview.md` §11](https://github.com/zxu756/diy-harness/blob/main/docs/overview.md) 和
[`TASKS.md`](https://github.com/zxu756/diy-harness/blob/main/TASKS.md)）。
仓库：<https://github.com/zxu756/diy-harness>；项目约定见 [`AGENTS.md`](https://github.com/zxu756/diy-harness/blob/main/AGENTS.md)
（每次任务都要跑测试、lint、快照校验，然后 commit + push）。

## 开发

```bash
uv sync                          # 安装依赖（Python 3.12）
uv run pytest                    # 单元测试（不需要 API key）
uv run ruff check .              # lint
uv run python scripts/sync_prompts.py --check   # 校验提示词快照与固定 commit 一致
bash scripts/ci_sim.sh                          # 本地把 CI 的 check job 跑一遍（沙箱 HOME + CI 终端环境）
```

改打包 / 依赖后再跑一条 `uv run python scripts/check_wheel.py`（`uv build` 出 sdist + wheel，干净 venv 里两种产物各装一遍 → 读提示词 + `dh debug prompt`）。

这三条也是 **CI 门**（[`.github/workflows/ci.yml`](https://github.com/zxu756/diy-harness/blob/main/.github/workflows/ci.yml)）：push / PR 自动跑；
快照校验那步会先把上游固定 commit 拉到 `~/deepagents`（只拉那一个 commit）。
`dh eval --split holdout --json` 放在手动触发里（要 `OPENCODE_API_KEY`，没配就跳过）。

## 安装（让 `dh` 在任何目录都能用）

```bash
uv tool install diy-harness      # 从 PyPI 装（0.1.0 起；`pipx install diy-harness` 同效）
uv tool install --editable .     # 本地开发：装当前源码（改代码立即生效，`dh evolve` 用这条）
cp .env ~/.diy-harness/.env      # 可选：把 key 放全局位置，任意目录都能直接聊（chmod 600）
dh --version                     # 任意目录都能敲
```

- **为什么用 `--editable`**：`dh` 会跑**当前源码**（自改循环 `dh evolve --repo .` 改完立刻就生效），
  不用重装。改了依赖（`pyproject.toml`）后重跑一次这条命令即可；`uv tool list` 看装了哪些。
- **key 的查找顺序**：环境变量 → `./.env` → `~/.diy-harness/.env`。放全局那份之后，
  在任何项目目录敲 `dh` 都能直接开始（项目自己的 `./.env` 可以覆盖它）。
- **不想要 editable 也行**：`uv tool install .`（或从 PyPI 装）装普通 wheel 一样能用 —— 构建钩子会把
  `prompts/` 复制进包里（`diy_harness/_prompts/`），`prompts.py` 运行时优先读这份包内副本，找不到才回退仓库根。
  改打包方式或升级依赖后想确认没弄坏：`uv run python scripts/check_wheel.py`（`uv build` → 干净 venv 里
  **wheel 与 sdist 各装一遍** → 读全 55 个快照 + 跑 `dh debug prompt`；CI 里也会跑）。

## 使用

```bash
export OPENCODE_API_KEY=...      # 也可放 ./.env 或 ~/.diy-harness/.env
dh                               # 在当前项目直接开聊（装在 PATH 里之后；`uv run dh` 同效）
uv run dh chat "你好"            # 一次性提问
uv run dh chat                   # 交互模式：/new 重开、/exit 退出、""" 多行
uv run dh chat --show-reasoning  # 显示思考过程（stderr）

uv run dh run "把 README 里过时的命令改掉"                      # 无交互跑一个任务（M1）
uv run dh run "跑测试并修复失败用例" --shell-allow recommended,python3,git
uv run dh run "..." --max-steps 20                              # 步数用尽时退出码 3
uv run dh run "..." --rubric rubric.md                          # 收尾前让独立 grader 按 rubric 评（M4）
uv run dh ralph "把课程写下去" --iterations 5                    # 每轮全新上下文，进度只留在文件系统与 git（M4）
uv run dh research "对比 Ralph 与 rubric 评审"                   # 深度研究：并行研究员 → final_report.md（M4，需要 TAVILY_API_KEY）
uv run dh mcp list                                              # 看 .diy-harness/mcp.json 里挂了哪些 MCP 工具（M4）
uv run dh eval --split train                                    # 跑 eval 套件：量 harness 改动值不值（M6）
uv run dh eval --split holdout --json                           # 只用 holdout 判改动（退出码 0 = 全过）
uv run dh say <thread> "顺手把 4 月也汇总一节"                   # 给跑着的会话派活（收件箱，随时可用）
uv run dh tail <thread> -f                                      # 看它会话在干什么（像 tail -f）
uv run dh serve                                                 # 常驻会话 + 网页（手机/远程：发话、看流式、点审批；M8）
uv run dh resume <thread>                                       # 接上继续聊；正在别处跑时会拒绝（改用 dh say）
uv run dh chat --auto-approve                                   # 无人值守：写文件与 execute 不再询问
uv run dh debug prompt                                          # 看拼装结果（不需要 key）
```

**常驻会话**（挂着一直聊、随时派活）：`dh chat` 的 thread 有**收件箱**和**单会话锁**。
把 `dh chat` 挂在 `tmux` / 后台 pty 里，然后在任何终端 `dh say <thread> "…"` 就能派活 ——
它在**下一轮开始前**读收件箱（一次消费），所以不用等你在那个终端里敲键；`dh tail` 看进度。
同一个 thread 同一时间只允许一个进程写历史：`dh resume` 撞上正在跑的会话会拒绝（报持有者 PID），
`dh say` 不拿锁，什么时候都能投。会话历史照旧每轮落盘（`~/.diy-harness/sessions/<thread>.jsonl`），
收件箱与锁是同目录的 `<thread>.inbox.jsonl` / `<thread>.lock`。

**在项目里直接开聊、随时换会话**（像 Claude Code / Codex 那样）：

```bash
cd ~/my-project
uv run dh                 # 直接开聊；thread 记下这是哪个目录开的
uv run dh -c              # 接上本项目最近一个会话（没有就提示直接敲 dh）
uv run dh -r <ID>         # 接上指定会话（ID 或前缀）
uv run dh sessions        # 列会话：● = 本项目，别的地方的会带上 cwd
uv run dh sessions --here # 只看本项目的
```

会话里（这些命令名对齐上游 dcode；**打字时敲 `/` 会弹出菜单**，上下键选、回车执行，`/resume `
后面还会补会话 ID）：

| 命令 | 作用 |
|---|---|
| `/threads`（别名 `/sessions`） | 列会话：序号 + ID + 时间 + 条数 +（别目录的）cwd，当前那个打 `*`；**带序号就直接切过去**（`/threads 2` = `/resume 2`） |
| `/resume <序号\|前缀\|ID>` | 切过去接着问（换 thread、换历史、锁跟着搬；失败只提示不退出） |
| `/clear`（别名 `/new`） | 开新 thread（锁搬到新 thread，收件箱也跟着换） |
| `/tokens`（别名 `/usage`） | 这个会话用了多少 token：输入/输出/合计、缓存命中率、上下文占用、最近几轮的用量 |
| `/cost` | 这个会话花了多少：token、缓存命中、**估算花费**（价格表可覆盖：`~/.diy-harness/prices.json`）、上下文占用百分比 |
| `/cwd`（别名 `/dir`、`/pwd`） | 当前工作区与会话 ID |
| `/tools` | 这次会话额外挂上的工具（MCP / 客户端 fs 等）+ 怎么看全量 |
| `/skills` | 工作区里的 `SKILL.md`（渐进披露那套） |
| `/memory` | 这次注入了哪些记忆文件（工作区 + 全局，带字符数） |
| `/glossary` | 工作区 `GLOSSARY.md` 的词条 |
| `/init` | 在工作区写一份 `AGENTS.md` 骨架（已存在就不动） |
| `/model [<ID>]` | 不带参数看当前模型；带参数换一个（只影响后面的轮次） |
| `/mode`（`/mode auto`） | 看/换审批档位：`manual`（都问，默认）/ `auto`（写文件放行、`execute` 问）/ `yolo`（都不问）；状态栏只在非 manual 时显示 |
| `/compact`（别名 `/offload`） | 把旧历史摘要掉、腾上下文（会调模型，原文备份到工作区） |
| `/help`（别名 `/h`、`/?`） | 列这些会话命令 |
| `/exit`（别名 `/quit`、`/q`） | 退出 |

其它子命令：`dh acp` 把 `dh` 当 **ACP 服务端**跑在 stdio 上（Zed 这类编辑器的
`agent_servers` 可以连它；协议层自己实现，见 `docs/overview.md` §11 M7-D）。
Zed 的接法（写进 `~/.config/zed/settings.json`；本机已实测到「Zed 起得来 + 配置无报错」，
**面板里选 agent 并发第一句话要你点一下**——Zed 只在 Agent 面板打开时才 spawn agent server）：

```jsonc
{
  "agent_servers": {
    "dh": {
      "command": "dh",
      "args": ["acp"],
      "env": { "OPENCODE_API_KEY": "oc_sk_..." }  // 或在项目 .env 里给它（dh 自己会读 .env）
    }
  }
}
```

协议能力：`initialize` 报 `loadSession`；`session/new` 给三档审批模式（`manual` / `auto` / `yolo`）并用 `session/set_mode` 切；
`session/load` 按 `threadId` 或「本目录最近的会话」接回历史。
客户端报了 `fs` 能力时，会话里会多出 `client_fs_read` / `client_fs_write` 两个工具（走编辑器自己的文件 API，未保存的缓冲区也读得到）；
`session/new` 带的 `mcpServers` 会并进工作区 `.diy-harness/mcp.json`（同名以你已经配好的为准）。
面板里：`cmd-?` 打开 Agent 面板 → 顶部选 `dh` → 发一句话（第一次会自己跑 `session/new`）。
端到端自检：`uv run python scripts/acp_smoke.py`（起真进程 + 管道，跑一轮流式回答 + 一次审批 + 切档位 + 接历史；
需要 `OPENCODE_API_KEY`，缺了会跳过）。

**两种 TUI**：默认是**行内版**（下面的输入行常驻，输出往上滚）；加 `--fullscreen` 是**整屏版**
（接管备用屏：可滚动的聊天面板 + 输入区 + 状态栏 + 弹窗，退出后原画面回来）。两版共用同一套
「跑一轮 / 排队 / Esc 打断 / 审批」的语义（`engine.SessionEngine`），只换屏幕怎么用。

**行内版（默认）**：输入行固定在屏幕最下面，输出往上滚 —— **模型跑着的时候输入行也还在**：

```text
● edit_file(src/diy_harness/tui.py)                ← 工具卡：一行一条，参数缩成相对路径
  @@ -1,3 +1,4 @@
  -old line
  +new line                                        ← 编辑类工具画彩色 diff（绿加红减）
  ⎿  Successfully replaced 1 instance(s)（共 3 行）   ← 工具结果：首行 + 行数，出错标红
⠹ 正在跑 12.3s · 已排队 1 条 · esc 打断 · opencode-go · deepseek-v4.1-flash · 本会话 22.4k↑ 258↓ · 缓存 100%（16.5k/16.5k） · 上下文 7.7k
```

- **跑活时继续打字**：回车把消息**排队**（状态栏显示「已排队 N 条」），这一轮完了自动发。
- **Esc 打断**：插旗子让 agent 循环在下一个关口停（这一轮整轮丢弃，与 Ctrl-C 一致）；空闲时
  Esc / Ctrl-C 清输入行，空行上 Ctrl-C 收工。状态栏跑活那段会显示耗时与转圈。
- 敲 `/` 弹菜单：**↑↓ 选、回车执行**（没按过 ↑↓ 就是第一项；`/` + 回车 = 先把 `/threads` 填进输入行，
  再回车才跑）。`/th` + 回车也能认（按唯一前缀补齐）；`/res 1` 会规范化成 `/resume 1`。
- `@` 触发文件补全：回车把路径接进句子、**留在输入行**（不替你发送），写完再回车才发。
- 菜单开着时状态栏第一段会提示 `↑↓ 选 · Enter 执行`。
- Alt+Enter / Ctrl+J 换行；↑ 翻历史（存在 `~/.diy-harness/history.txt`）。
- 标题加粗、代码块压灰、`` `行内代码` `` 上色；正文按行流式渲染（不做全量重排，取舍见 `docs/tui.md`）。
- 数字是跨轮累加的，`dh -c` 接上老会话时会从 thread 里落盘的 `usage` 复原（不会假装从 0 开始）。
- 非 TTY（管道、脚本）会自动退回逐行模式；想手动关：`dh --plain`（`dh chat --plain`、
  `dh resume <ID> --plain` 同）。

**整屏版**：`dh --fullscreen`（`dh chat --fullscreen`、`dh resume <ID> --fullscreen` 同）。

```text
┌ 聊天面板（可滚动，跟随最新）──────────────────────────────────────────┐
│ ● edit_file(src/diy_harness/tui.py)                                  │
│   @@ -1,3 +1,4 @@                                                    │
│   +new line                                                          │
│   ⎿  Successfully replaced 1 instance(s)（共 3 行）                    │
├ 输入区（多行；`/` 弹菜单、`@` 补文件）────────────────────────────────┤
│ dh> 把刚才那处再改回去_                                              │
└ 状态栏：⠹ 正在跑 12.3s · 已排队 1 条 · esc 打断 · provider · 模型 · tokens ┘
```

| 键 | 干什么 |
|---|---|
| `Enter` | 提交（`/` 菜单里高亮的那项先接进来，与行内版同一套语义） |
| `Alt+Enter` / `Ctrl+J` | 输入区换行（多行提问） |
| `Esc` | 关弹窗 > 打断这一轮 > 清输入行 |
| `Ctrl+C` | 同上；输入行空着又没在跑就收工 |
| `PgUp` / `PgDn` / `Ctrl+U` / `Ctrl+D` / 滚轮 | 翻聊天面板（一屏 / 半屏 / 小步） |
| `Ctrl+Home` / `Ctrl+End` | 跳到最老 / 回到最新（恢复自动跟随） |
| `/threads`、空参数的 `/resume` | 弹**会话选择器**：↑↓ 或数字选、Enter 切过去、Esc 关掉 |
| 审批时 `y` / `a` / `n` | 批准 / 这一段都不再问 / 拒绝（Esc 也=拒绝） |

管道、非 TTY 或 `--plain` 时照旧退回逐行模式（整屏版要真终端）。想用终端原生选择复制文字时按住 Shift 拖。

没装 tmux 也能挂（脚本只负责给 `dh chat` 一个 pty，派活还是走 `dh say`）：

```bash
uv run python scripts/pty_daemon.py --log /tmp/dh-chat.log -- dh chat --auto-approve   # 常驻；Ctrl-C 或 kill 收尾
uv run python scripts/pty_daemon.py --fifo /tmp/dh-chat.in -- dh chat                  # 想模拟键盘敲键时
printf '/exit\n' > /tmp/dh-chat.in                                                     # 从 FIFO 里敲 /exit
```

配了 `TAVILY_API_KEY`（环境变量、`./.env` 或 `~/.diy-harness/.env`）时，工具里多一个 `web_search`；
`fetch_url` 一直都在。MCP 服务器写在 `.diy-harness/mcp.json`（形状同 Claude / dcode），
工具名是 `mcp__<服务器>__<工具>`。

**MCP 工具也走审批闸门**（2026-09-28 补的洞）：`mcp__*` 工具在 `manual` 档一律问一句；
`auto` 档只放行名字看着只读的（`read` / `list` / `get` / `search` / `fetch` / `info` / `tree`），
写类与认不出来的照样问；`yolo` 档放行。MCP 是外来的能力，没法凭名字保证安全。

**成本提醒**：挂一个服务器就等于每次请求都带上它全部工具的说明（本机试的文件系统服务器 14 个工具 ≈ 10k token/次）。
不用的时候把 `.diy-harness/mcp.json` 删掉或注释掉就行（`dh mcp list` 随时能看挂载情况）。

默认走 OpenCode Go（`https://opencode.ai/zen/go/v1`）的 `deepseek-v4.1-flash`；
`--provider opencode-go|opencode|deepseek`、`--model` 可切换。请求头带 `diy-harness/<版本>`
与每个会话固定的 `x-opencode-session`。

## 自演进（`dh evolve`）

`dh evolve` 是个无人值守循环：**plan → work → check → record**，一轮改一个增量。工作区里要有
`MISSION.md`（只读，启动时记 SHA-256，被改过就停）与 `STATUS.md`（状态思考 + 每轮记录），
全过程另存 `.diy-harness/evolve/`（`journal.md` append-only 日志、每阶段完整输出、A/B 快照、候选快照）。

```bash
# 在一个新目录里跑（工作区里先放 MISSION.md）
dh evolve --rounds 3 --max-steps 25 --max-tokens 400000

# harness 自改（M5-D）：把自己当工作区，判分器是 dh eval
dh evolve --repo . --ab --candidates 2 \
  --allow-paths src/diy_harness,prompts,docs,tests,TASKS.md,README.md \
  --plan-max-steps 10 --max-steps 30
```

三个让「自改」可控的开关（都能在命令行上调，按自己的想法配）：

| 开关 | 作用 |
|---|---|
| `--ab` | work **前后各跑一遍**任务书里的验收命令，把「这轮是变好还是变差」写进记录（`- A/B：通过 0/2 → 2/2；退步 0 条`），check 阶段也看得到这份对照 |
| `--candidates N` | 一轮最多试 N 条候选：每条 work→check，评审不过就**回滚**（文件快照，不依赖 git）再试下一条；失败的也记账 |
| `--allow-paths` | work/check **只许写**这些前缀（相对工作区）。越界当场被拒、记账、本轮退出码 7；`MISSION.md` / `STATUS.md` / `.diy-harness/` 永远不许写 |

退出码：`0` 正常 / `3` 步数用尽 / `4` 预算到点 / `5` 评审连续 FAIL / `6` 任务书被改 / `7` 越界写入。
操作闸门：`.diy-harness/STOP` 存在即停；`.diy-harness/STEER.md` 写一句话给下一轮当指令（读一次后清空）。

## 定时自己干活（`dh schedule`）

清单写在工作区 `.diy-harness/schedule.toml`：

```toml
[[job]]
thread = "20260928-073801-06d72d"   # 会话 ID（可只给前缀）
when = "every 6h"                    # in 30m / every 6h / at 2026-09-30 08:00 / daily at 08:00
prompt = "检查 CI，挂了就修"
```

```bash
dh schedule list    # 列出来（含上次跑过的时间）
dh schedule run     # 把到点的活投进那个会话的收件箱（跑一次就退出，丢给 cron/launchd 即可）
```

到点的活走的是 `dh say` 同一条路（收件箱），所以「会话在跑就先排队、不在跑就留着等下轮」这套语义直接复用；
`in` / `at` 是一次性的（投完标 `done`），`every` / `daily` 记 `last_run`。**我们不做常驻守护进程** ——
触发交给系统 cron / launchd，这也顺应上游 `libs/talon` 的结论（见 `docs/upstream-audit.md` §四.3）。

## 手机 / 远程（`dh serve`）——第三个 UI

`dh serve` 把常驻会话开成一个网页（HTTP + SSE）：手机浏览器打开就能**发话、看流式输出、点审批**。
干活仍然全部走 `engine.SessionEngine` —— 排队 / 打断 / 审批与两个 TUI 是同一份实现，
所以 `dh say` / `dh tail` / `dh resume` 在这边照样能用（同一个 thread、同一把锁）。

```bash
dh serve                        # 本项目最近的会话（没有就新开）；打印带 token 的地址
dh serve --mode auto            # 写文件放行、execute 仍问（问了就在页面上点）
dh serve --port 8787 --token …  # 端口与令牌（省略则读/建 ~/.diy-harness/serve-token，0600）
```

页面上的动作对应四条 POST：`say`（≡ `dh say`，走收件箱）、`approve`（`y` = 允许一次 / `a` = 这一段都允许 /
`n` = 拒绝）、`interrupt`（等价在终端按 Esc）、`shutdown`（常驻进程收工）。审批卡片带 diff；
没人回（默认 300 秒）按**拒绝**处理。SSE 带最近 200 条回放，手机断线重连不用从头再聊。

**安全**：默认只绑 `127.0.0.1`；要手机访问优先 `tailscale serve --bg 8787`（自动 HTTPS、只在 tailnet 内可见），
执意直连再加 `--allow-remote`。所有路由都要 token（页面与 SSE 走 `?token=`，其余走 `Authorization: Bearer`）。

**不想开网页？** 还有零代码那条路：手机 SSH 进 Mac，`dh say` 派活 / `dh tail` 看进度 / 往键盘 FIFO
`printf 'y\n'` 答审批 —— 起法、四个坑与彩排记录见 [`docs/mobile.md`](https://github.com/zxu756/diy-harness/blob/main/docs/mobile.md)（`scripts/resident.sh`）。

## 沙箱与安全边界（M9）

**现状**：**M9-A（补洞）已落地**；容器后端（M9-B）与网络白名单（M9-C）**未做** —— 细节与局限见
[`docs/sandbox.md`](https://github.com/zxu756/diy-harness/blob/main/docs/sandbox.md)。不带 Docker 也已经生效的三件事：

1. **子进程环境是白名单**：`execute` 与 MCP 服务器只继承 `PATH` / `HOME` / `LANG` / `LC_*` … 这类基础变量；
   `*_API_KEY` 之外的密钥名（`GITHUB_TOKEN`、`AWS_SECRET_ACCESS_KEY`）一并不给。
   配置里显式写的 `env`（含 `${VAR}` 展开）照给 —— 那是用户自己的选择。
2. **密钥文件对工具与命令不可见**：`~/.diy-harness/.env`、`serve-token`、`trusted.json`、工作区根的 `.env*`
   —— `read_file` / `grep` / `glob` 都碰不到，`cat ~/.diy-harness/.env` 这类命令会被拦下；
   `.env.example` 这类模板照旧可见。记忆 / 词表 / skills 所在的 `~/.diy-harness/agent/` 不受影响。
3. **工作区配置要信任**：`.diy-harness/hooks.toml` 与 `mcp.json` 是会**执行命令**的配置。交互模式第一次会问
   一句 `[y/N]`；非交互入口（`run` / `ralph` / `serve` / `acp` / `evolve` / `mcp list`）默认**不加载**并打一行说明：

```bash
dh run "…" --trust-config        # 显式信任（记进 ~/.diy-harness/trusted.json，之后不再问）
rm ~/.diy-harness/trusted.json   # 全部撤销（内容一改其实就自动失效）
```

没有沙箱却用 `--mode yolo` 或 `--shell-allow all` 时，启动会打一行红字、TUI 状态栏也把它排在最前面 ——
那等于把宿主机交给模型，只适合在一次性目录里跑（容器支持是 M9-B）。

**容器后端（M9-B）**：`--sandbox docker` 让 `execute` / MCP / hooks / evolve 验收命令进容器
（无网络、只有工作区可写、没有 key、有资源上限），文件工具与 API key 留在宿主机：

```bash
dh sandbox build                 # 一次性：构建镜像（docker/sandbox/Dockerfile + 代理镜像）
dh chat --sandbox docker         # 交互模式进容器
dh run "把测试修到全绿" --sandbox docker
dh chat --sandbox docker --sandbox-net allowlist   # 网络只走代理、只放名单里的域名（M9-C）
dh mcp list --sandbox docker     # MCP 服务器也进容器（M9-D）；需要网络的服务器在 mcp.json 里写 sandboxDomains
DIY_HARNESS_SANDBOX=docker dh    # 环境变量版
dh sandbox gc                    # 清掉属主进程已死的残留容器/网络
```

细节（四条边界、四类攻击的红队、局限）见 [`docs/sandbox.md`](https://github.com/zxu756/diy-harness/blob/main/docs/sandbox.md)。

## 钩子（`hooks.toml`）——「每次都要做的事」交给 harness

工作区里写 `.diy-harness/hooks.toml`，三条钩子都是**命令模板**（不用写 Python）：

```toml
[hooks]
session_start = "echo 开工：{workspace}"
before_tool = "python3 tools/policy.py {tool} {path}"   # 非 0 退出 = 拦下这一步
sort_check = ""                                          # ← 不认识的键会直接报错，不会静默忽略
after_tool = "ruff format {path} 2>/dev/null || true"   # 只在 write_file / edit_file 成功之后跑
```

占位符：`{tool}`、`{path}`、`{command}`（execute 的命令）、`{workspace}`；`before_tool` 想拦就 `exit 1`
（模型会收到「被 before_tool 拦下」并看到你的输出），`after_tool` 失败只提示、不回滚。没有这个文件就是全关（默认什么都不做）。

## 发布（PyPI，M10）

包名 `diy-harness`（命令仍是 `dh`；PyPI 上 `diy-harness` 与 `diy_harness` 是同一个项目）。
**版本号只有一处**：`pyproject.toml` 的 `version` —— 另外只多一个 `diy_harness.__version__`
（`dh --version` 读它），两处漂移有契约测试钉着（`tests/unit_tests/test_packaging.py`）。

```bash
uv version --bump minor                                  # 0.1.0 → 0.2.0；再手动改 src/diy_harness/__init__.py
uv run pytest tests/unit_tests/test_packaging.py -q      # 漏改一处在这里就挂
git commit -am "M10：版本 0.2.0"
git tag v0.2.0 && git push origin main v0.2.0            # 推 tag = 发布（见下）
```

- **推 `v*` tag 就是发布**：[`.github/workflows/publish.yml`](https://github.com/zxu756/diy-harness/blob/main/.github/workflows/publish.yml)
  先查 tag 与 `pyproject` 的版本一致（不一致直接拒 —— 版本号在 PyPI 上用掉就回不来）、跑测试与产物验收、
  `uv build` + `uvx twine check`，然后经 **trusted publishing**（GitHub OIDC）上传：仓库里不存任何 PyPI token。
  `workflow_dispatch` 只做「构建 + 校验」那一半，想先看产物过不过就手动跑一次。
- **PyPI 侧只配一次**：<https://pypi.org/manage/account/publishing/> 建 pending publisher ——
  Owner `zxu756`、Repository `diy-harness`、Workflow `publish.yml`、Environment `pypi`
  （最后一项要和 workflow 里的 `environment:` 一字不差）。
- **手动发（本机，要 token）**：`uv build && uv publish`（token 走 `UV_PUBLISH_TOKEN` 或 `--token`）。
  想先拿 TestPyPI 试水：配一个 `[[tool.uv.index]]`（`publish-url` 指 TestPyPI），再 `uv publish --index testpypi`。
- **发布前自检四条**：`uv run pytest`、`uv run ruff check .`、`uv run python scripts/check_wheel.py`、
  `uvx twine check dist/*`。README 就是 PyPI 上的长描述，**正文里的链接一律写绝对地址**（相对链接在 PyPI 上点不开）。
- **版本策略**：0.x 期间功能里程碑 `+minor`、修 bug `+patch`；上传成功的版本号不能改也不能删，只能 yank。

## 目录

```
evals/              评测套件：25 个固定任务（train 15 / holdout 10）+ 判分脚本 + 成绩单（M6/M6-I）
prompts/upstream/   上游提示词快照 55 个（脚本生成，不手改；来源见 UPSTREAM.md）
prompts/dcode/      派生的 dcode 主提示词（无交互变体；删改登记在 DEVIATIONS.md）
prompts/DEVIATIONS.md   派生提示词的改动记录
scripts/sync_prompts.py 从固定 commit 抽取快照（--check 只比对）
scripts/pty_daemon.py   给常驻会话一个 pty 的最小载体（没 tmux 时用；不属于运行时）
src/diy_harness/    运行时（agent / tools / llm / prompts / prompting / chat / terminal / cli / config）
tests/unit_tests/   单元测试
tests/integration_tests/  真实 API 调用（需要 key，用 -m integration 跑）
```
