diff --git a/.gitignore b/.gitignore
index d62ce77..9b3d7d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
node_modules/
out/
+!tools/desk/fixtures/*/out/
renders/
.venv/
references/repos/
diff --git a/AGENTS.md b/AGENTS.md
index a77aecb..3064000 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@
2. **读流程和自查**:读 `playbook/01-pipeline.md` 和 `playbook/02-verification.md`;涉及配音或音乐时,再读 `playbook/04-audio.md`。
3. **建项目**:运行 `bin/vh new `,它会建好 `projects/<日期>-/`,复制模板,并把该类型的 prompt 块填进 BRIEF(类型的默认做法,立意定了再取舍);手绘类还会复制引擎、装好依赖。其他引擎的初始化方式见 `engines/README.md`。
4. **先想立意,再看案例和参考**:建完项目,先把材料里只属于它的数字、原话、物件列进 NOTES.md 的素材清单,自己想 5–8 个点子;这时先不看 BRIEF 末尾的 TYPE 块、`styles/`、`recipes/` 和案例,免得点子往看过的东西上靠。然后再看下面这些参考,用它们补强,收敛成 2–3 张立意卡(做法见 `playbook/12-ideation.md`,例子见 `cases/oneshot-five.md`)。人已经给了立意,就记进 BRIEF,不再出卡,最多附一个备选。参考:打开类型文档"可参考的案例"和"社区 skill 参考"两节列出的文件;需要看真实代码时读 `references/repos/` 里的源码。本仓库自己做过的片子在 `showcase/`,各带 BRIEF、STORYBOARD、NOTES 和源码,是最直接的样板。分镜时,镜头怎么动、全片节奏怎么排,先查 `recipes/`:按意图和能量筛(`bin/vh recipes list`),把选中的配方 id 写进 STORYBOARD 的"配方"列,写代码前读配方全文。
-5. **停下来给人审**:`standard` 和 `studio` 必过下面三道关卡,`quick` 不设关卡;人点名要亲自拍板的事,按"导演模式"另外停,`quick` 也一样。每次停默认做一页决定优先的审阅页(`bin/vh review` 生成本地 HTML,做法见 `playbook/01-pipeline.md` 的"审阅页"),聊天里只发要人定的事和页面路径;只有一两个纯文字的选择时,直接在聊天里问也行。然后**停下来等人回复**,不要自己往下做:
+5. **停下来给人审**:`standard` 和 `studio` 必过下面三道关卡,`quick` 不设关卡;人点名要亲自拍板的事,按"导演模式"另外停,`quick` 也一样。每次停默认做一页决定优先的审阅页(`bin/vh review` 生成本地 HTML,做法见 `playbook/01-pipeline.md` 的"审阅页"),聊天里只发要人定的事和页面路径;只有一两个纯文字的选择时,直接在聊天里问也行。`studio` 默认在审阅台停(`bin/vh desk`,同一份 gate JSON,人在页面里逐条勾选、点评、提交):停之前在后台挂上 `bin/vh desk wait`,人一提交就接着做;没有后台唤醒的 agent 退回"人回聊天说一声"(`playbook/01-pipeline.md` 的"审阅台")。审阅页和审阅台的界面语言按用户第一句话的语言定(BRIEF 的 `Review language`,`bin/vh new … --lang en`)。然后**停下来等人回复**,不要自己往下做:
- ① **立意和大纲**:2–3 张彼此拉得开的立意卡,每张写明由它推出的画面(专属风格,或参考 `styles/` 的预设,可以几个)和钩子,配一帧画面(`playbook/12-ideation.md` 第 7 节)。选一张卡,立意、风格方向和钩子就一起定了,人可以在回复里换预设或钩子。再附按推荐立意写的 BRIEF、3–7 段大纲、引擎和费用。不要默认只给一种口味;
- ② **分镜**:按大纲的段落拆页,每页 3–6 镜的关键帧,没把握的镜头标出来,逐镜默认通过。长片再附一版全长灰盒 animatic;
- ③ **初版**:draft 成片加联系表,并写出你自己最不满意的 2–3 处。
@@ -38,6 +38,7 @@
|---|---|---|---|
| 适合 | 试方向、草稿、随手发 | 大多数正式视频 | 发布片、旗舰内容、会被反复看的片子 |
| 人工关卡 | 0 道,直接出片。选 quick 就等于授权跳过关卡,把这一点记进 REVIEW.md;需求有歧义时最多问 1 个问题;人点名要拍板的事照样停(导演模式) | 3 道 | 3 道;关卡 ① 每张立意卡附一段 10–20 s 草图,关卡 ② 附全长 animatic |
+| 在哪审 | 不设关卡;导演模式要停的在聊天里问,或用静态审阅页 | 静态审阅页(`bin/vh review`);人想逐条勾选、点评时开审阅台(`bin/vh desk`) | 审阅台(`bin/vh desk`):人在页面里勾选、点评、提交;每次停后在后台挂上 `bin/vh desk wait`,提交就接着做;没有后台唤醒的 agent 退回"人回聊天说一声" |
| 立意 | 自己想 3 个一句话点子,挑 1 个,写一行理由 | 关卡 ① 给 2–3 张立意卡,每张配一帧画面 | 同左,每张配一段 10–20 s 的草图(look-dev) |
| 风格 | 从立意推出来,可以参考 `styles/` 的预设,写明借了什么 | 写在每张立意卡的"画面"一行(专属,或借一个预设);选卡就一起定了,人可以在回复里换 | 同左;每张卡的草图就是那种风格的小样,借来的预设人想看时在 E0 补小样 |
| 分镜 | 简表:镜头、时长、reads | 完整 STORYBOARD + 分镜预览图 | 同左 + animatic |
@@ -77,7 +78,7 @@ effort 管 agent 自己查得多细,导演模式管人拍板哪些事,两个
- `review`:agent 出一个结果,放进下一页(没有下一页就随交付给),不为它单独停,人不说话就算通过;
- `delegate`:agent 自己定,写进 `DECISIONS.md`,人随时能翻案。
- **没点名的按档位**:`quick` 全部 `delegate`;`standard` 的 `concept` 是 `own`(风格和钩子写在立意卡上,选卡就一起定了;人单独点名 `style` 或 `hook` 时另问),`outline` 和 `storyboard` 是 `review`,其余 `delegate`;`studio` 同 `standard`,其余改成 `review`。
-- **每次停,默认做一页审阅页**,聊天里只发 `bin/vh review` 打印的几行;只有一两个纯文字的选择时,直接在聊天里问也行。一页默认最多 3 个决定,每个带推荐,上游的先问,其余默认通过;人想一次看更多也可以。人的原话逐字记进 `REVIEW.md`。人说不清哪里不对时,先做 look-dev:同一段 10–20 s,只改一个变量,出 2–3 个变体。
+- **每次停,默认做一页审阅页**,聊天里只发 `bin/vh review` 打印的几行;`studio` 再开审阅台(`bin/vh desk`),把它的地址一起发;只有一两个纯文字的选择时,直接在聊天里问也行。一页默认最多 3 个决定,每个带推荐,上游的先问,其余默认通过;人想一次看更多也可以。人的原话逐字记进 `REVIEW.md`。人说不清哪里不对时,先做 look-dev:同一段 10–20 s,只改一个变量,出 2–3 个变体。
- **关卡是底线**:`standard` 和 `studio` 的关卡 ①②③ 照停,导演模式只加停、不减停;要全程不停,只能由用户本人说"不用审、直接出"。
## quick 路径卡
@@ -147,13 +148,13 @@ OpenVideoHarness/
├── install.sh 一键安装(克隆、依赖、参考仓库、注册 skill)
├── CONTRIBUTING.md 改本仓库本身时的分支、PR 和推送规则(人和 agent 都适用)
├── .github/ CI(Linux + macOS 跑 tools/ci.sh)和 main 分支的规则集
-├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review
-├── tools/ bin/vh 背后的脚本(audio/:tts、captions、beats、music、sfx、mix、qa;sheet.py、readcheck.py、review.py;给人拍板用的图:storyboard.py、rhythm.py、style_compare.py、cover_preview.py、audio/roll.py,共用 vhdraw.py);ci.sh 是仓库自检;fetch_media.sh 从 Release 下载样片视频
+├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review · desk
+├── tools/ bin/vh 背后的脚本(audio/:tts、captions、beats、music、sfx、mix、qa;sheet.py、readcheck.py、review.py;desk/:审阅台的服务、读取约定和等待提交,约定写在 desk/README.md;给人拍板用的图:storyboard.py、rhythm.py、style_compare.py、cover_preview.py、audio/roll.py,共用 vhdraw.py);ci.sh 是仓库自检;fetch_media.sh 从 Release 下载样片视频
├── skills/open-video-harness/ 轻量 skill:在任何目录把做视频的请求引到本仓库
├── video-types/ 9 类视频(09 实验中):工作流、审美、禁止项、prompt 增量块、自查重点、案例、社区 skill
├── playbook/ 跨类型的通用知识
│ ├── 00-paradigm.md 范式与引擎选型
-│ ├── 01-pipeline.md 十阶段流程(0–9)、reads、人工关卡、导演模式和审阅页、subagent 并行
+│ ├── 01-pipeline.md 十阶段流程(0–9)、reads、人工关卡、导演模式、审阅页和审阅台、subagent 并行
│ ├── 02-verification.md 验证闭环与各引擎的取帧命令
│ ├── 03-motion-design.md 缓动、时长、排版、安全区、转场
│ ├── 04-audio.md 配音(双语)、字幕、配乐、音效、歌曲、混音
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f953998..8dc47e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
## Unreleased
+**The review desk: tick, comment and submit in a page, and the agent carries on by itself (`bin/vh desk`)**
+- Why: reviewing a film's first gate through the static pages, the maintainer found the outline, captions and stages hard to read (some were raw `.md` links), had no way to tick or comment in place, had to reply in chat with codes like "1 S1", and got a new one-off page every time. A prototype desk fixed that, and after two real rounds in it: "返回还是要手动给你触发,我觉得可以加一个监控", the outline should be split by content, not by 起承转合, reuse needs a basic infrastructure, and the desk belongs to `studio`, in Chinese or English by the first request.
+- `tools/desk/` (stdlib Python, offline, no CDN, light and dark): `bin/vh desk [--port N]` serves one project on 127.0.0.1 (the first free port of 8780–8799; a second start prints the running one's address). Ten views read fresh from the project's own files: this round (decisions with options, least sure, decided for you), concept and outline (a time bar by segment), captions or narration (grouped by segment, term chips, a rough reading speed), the storyboard synced to its video, sound (sections named from the gate JSON, listen prompts, timecoded notes), fact check, the film with timecoded notes (C key), the decision ledger, history, and every doc rendered as text. Any item can be marked ok / change / question with a line and a "change to"; a tray collects them and submits once.
+- A submission is checked (a real gate page, its decisions and options, sizes) and written as `out/review/feedback/-
" + "" * h.count("")), "3000 nested quotes")
+except RecursionError:
+ bad.append("3000 nested quotes: RecursionError")
+want(reader.render_md("a *b* c") == "a b c
" and reader.render_md("**b** ~~c~~") == "b c
", "inline markup")
+d = os.path.join(sys.argv[1], "order")
+shutil.copytree("tools/desk/fixtures/zh", d)
+g = os.path.join(d, "out", "review")
+shutil.copy(os.path.join(g, "gate-1.json"), os.path.join(g, "gate-1b.json"))
+os.utime(os.path.join(g, "gate-1.json"), (time.time() + 60, time.time() + 60)) # the earlier page touched later
+r = reader.read_project(d)
+want([x["id"] for x in r["rounds"]] == ["1", "1b"] and r["lang"] == "zh", f"order after a touch: {[x['id'] for x in r['rounds']]}, lang {r['lang']}")
+j = json.load(open(os.path.join(g, "gate-1b.json"), encoding="utf-8"))
+json.dump(dict(j, lang="en"), open(os.path.join(g, "gate-1b.json"), "w", encoding="utf-8"))
+want(reader.read_project(d)["lang"] == "en", "this round's gate JSON lang should override the BRIEF")
+fd = os.path.join(g, "feedback")
+os.makedirs(fd)
+for n in ("1b-20261004-030440", "1b-20261004-030440-2", "1b-20261004-030440-10"):
+ json.dump({"round": "1b", "submitted_at": "2026-10-04T03:04:40", "n": n}, open(os.path.join(fd, n + ".json"), "w"))
+want(feedback.latest(d)[1]["n"] == "1b-20261004-030440-10", f"latest within one second: {feedback.latest(d)[1]}")
+# the page bin/vh review made last is this round (out/review/.current), whatever the stop order or its name
+c = os.path.join(sys.argv[1], "current")
+shutil.copytree("tools/desk/fixtures/zh", c)
+cg = os.path.join(c, "out", "review")
+def publish(n):
+ shutil.copy(os.path.join(cg, "gate-1.json"), os.path.join(cg, f"gate-{n}.json"))
+ return subprocess.run([sys.executable, "tools/review.py", c, n], capture_output=True, text=True).returncode
+cur = lambda: reader.read_project(c)["rounds"][-1]["id"] # noqa: E731
+for seq, page in ((["2", "1b"], "1b"), (["3", "E3b"], "E3b"), (["final"], "final"), (["2B"], "2B")):
+ rcs = [publish(n) for n in seq]
+ want(rcs == [0] * len(seq) and cur() == page, f"published {seq} (exit {rcs}): this round is {cur()}, not {page}")
+fd2 = os.path.join(cg, "feedback")
+os.makedirs(fd2)
+for rid in ("2B", "E3b"):
+ json.dump({"round": rid, "submitted_at": "2026-10-04T12:00:00"}, open(os.path.join(fd2, f"{rid}-20261004-120000.json"), "w"))
+want([f.name for f, _ in feedback.pending(c)] == ["2B-20261004-120000.json"], f"pending follows .current: {[f.name for f, _ in feedback.pending(c)]}")
+os.remove(os.path.join(cg, "gate-2B.json")) # .current names a page that is gone: the review order decides
+want(cur() == "3", f"with .current pointing at a removed page: {cur()}")
+# bin/vh review: the page as spelled on disk (APFS finds gate-1d.json for 1D); no gate: this round, not the newest
+# file; ids of 1-24 characters; a gate file that links out of the project is not read
+shutil.copy(os.path.join(cg, "gate-1.json"), os.path.join(cg, "gate-1d.json"))
+rv = lambda *a: subprocess.run([sys.executable, "tools/review.py", c, *a], capture_output=True, text=True) # noqa: E731
+r = rv("1D")
+names = os.listdir(cg)
+want(r.returncode == 0 and open(os.path.join(cg, ".current")).read().strip() == "1d" and "gate-1d.html" in names and "gate-1D.html" not in names,
+ f"review 1D for gate-1d.json: exit {r.returncode}, .current {open(os.path.join(cg, '.current')).read().strip()!r}, {sorted(n for n in names if n.endswith('.html'))}")
+publish("E3b")
+os.utime(os.path.join(cg, "gate-3.json"), (time.time() + 60, time.time() + 60))
+r = rv()
+want(r.returncode == 0 and "gate-E3b.html" in r.stderr and "gate-3.json is newer" in r.stderr, f"review with no gate: {r.returncode} {r.stderr[-300:]}")
+r = rv("a" * 25)
+want(r.returncode == 2 and "1 to 24" in r.stderr, f"a 25-character id: {r.returncode} {r.stderr}")
+shutil.copy(os.path.join(cg, "gate-1.json"), os.path.join(sys.argv[1], "outside.json"))
+os.symlink(os.path.join(sys.argv[1], "outside.json"), os.path.join(cg, "gate-9.json"))
+want("9" not in [x["id"] for x in reader.read_project(c)["rounds"]] and rv("9").returncode == 2, "a gate file linking out of the project was read")
+# writers in several processes at once: each _write has its own temp file, .consumed's read-modify-write is locked
+code = ("import sys; sys.path.insert(0, 'tools/desk'); import feedback; from pathlib import Path\n"
+ "d, k = sys.argv[1], sys.argv[2]\n"
+ "for i in range(40):\n"
+ " feedback._write(Path(d) / 'out' / 'review' / 'feedback' / '.listening', k)\n"
+ " feedback.mark_consumed(d, [f'{k}-{i}.json'])\n")
+procs = [subprocess.Popen([sys.executable, "-c", code, c, f"w{k}"], stderr=subprocess.PIPE, text=True) for k in range(8)]
+errs = [(p.wait(), p.stderr.read()) for p in procs]
+parts = [n for n in os.listdir(fd2) if n.endswith(".part")]
+want(all(rc == 0 for rc, _ in errs) and len([n for n in feedback.consumed(c) if n.startswith("w")]) == 320 and not parts,
+ f"8 concurrent writers: exits {[rc for rc, _ in errs]}, {len(feedback.consumed(c))} names, leftovers {parts}, {[e[-200:] for _, e in errs if e][:1]}")
+if bad:
+ print("; ".join(bad))
+sys.exit(1 if bad else 0)
+PY
+ if a=$(python3 "$t/order.py" "$t"); then ok "desk: this round is the page bin/vh review made last (1b after 2, E3b after 3, final, 2B; pending follows it), else review order (a touched file doesn't win); the gate JSON's lang overrides the BRIEF; long and deeply nested markdown in bounded time; -10 after -2"
+ ok "review: 1D renders gate-1d.json as 1d; no gate renders this round (and names a newer file); ids over 24 characters and gate links out of the project are refused; 8 concurrent writers all exit 0 with every name kept"
+ else bad "desk order / lang / markdown / latest / review ids / concurrent writers: $a"; fi
+ # a server on a free port, on a copy (a submission writes into the project); a hidden file, a link to it, an upper-case
+ # .ENV and an SVG with a script in it: none may be served same-origin
+ cp -R tools/desk/fixtures/zh "$t/p" && printf '
\n' >> "$t/p/STORYBOARD.md"
+ printf 'KEY=secret\n' > "$t/p/.env" && ln -s .env "$t/p/visible.txt" && printf 'KEY=secret\n' > "$t/p/keys.ENV"
+ printf '\n' > "$t/p/ref.svg"
+ if ! desk_start "$t/p" "$t/srv.log"; then rm -rf "$t"; return; fi
+ pid=$desk_pid; url=$desk_url
+ cat > "$t/client.py" <<'PY'
+import http.client, json, socket, sys, urllib.parse
+u = urllib.parse.urlsplit(sys.argv[1]); proj = sys.argv[2]; what = sys.argv[3]
+def req(method, path, body=None, headers=None):
+ c = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
+ h = {"Host": f"127.0.0.1:{u.port}"}
+ h.update(headers or {})
+ c.request(method, path, body=body, headers=h)
+ r = c.getresponse(); data = r.read(); c.close()
+ return r.status, r.headers, data
+bad = []
+def want(cond, msg):
+ if not cond: bad.append(msg)
+s, _, page = req("GET", "/")
+token = page.decode().split('name="desk-token" content="')[1].split('"')[0]
+fb = lambda **k: json.dumps(dict({"round": "1", "lang": "zh", "decisions": {"look": "L2"}, "defaulted": [], "general": "",
+ "items": [{"target": "seg:1", "status": "change", "label": "大纲 第 1 段", "text": "开头再快一点 ``` 第 0 帧就要有水声"}]}, **k)).encode()
+H = {"Content-Type": "application/json", "X-Desk-Token": token}
+if what == "serve":
+ s, _, d = req("GET", "/api/data")
+ d = json.loads(d) if s == 200 else {}
+ sb = next((x for x in d.get("docs", []) if x["file"] == "STORYBOARD.md"), {"sections": [], "intro": ""})
+ html = sb["intro"] + "".join(x["html"] for x in sb["sections"])
+ want(s == 200 and d["project"]["slug"] == "p" and "<img src=x" in html and "
"$t/wait.out" 2>&1 & wpid=$!
+ for i in $(seq 50); do [ -f "$t/p/out/review/feedback/.listening" ] && break; sleep 0.2; done
+ a=$(python3 "$t/client.py" "$url" "$t/p" submit); rc=$?
+ wait "$wpid"; i=$?
+ if [ $rc = 0 ] && [ $i = 0 ] && grep -q '第 0 帧就要有水声' "$t/wait.out" && [ ! -e "$t/p/out/review/feedback/.listening" ] \
+ && python3 - "$t/p/REVIEW.md" <<'PY'
+import re, sys
+s = open(sys.argv[1], encoding="utf-8").read()
+m = re.search(r"^## 审阅台反馈 · 关卡 1 · .*?(?=^## )", s, re.M | re.S)
+sys.exit(0 if m and "开头再快一点 ``` 第 0 帧就要有水声" in m.group(0) and "\n````text\n" in m.group(0)
+ and s.index("## 审阅台反馈") < s.index("## 授权跳过怎么记") and "决定 look:L2" in m.group(0) else 1)
+PY
+ then ok "desk: a submission wakes bin/vh desk wait (exit 0) and goes into REVIEW.md verbatim, fenced, before the template's reference sections"
+ else bad "desk submit + wait: client '$a' rc $rc, wait rc $i: $(head -c 300 "$t/wait.out")"; fi
+ a=$(vh desk feedback "$t/p" --round 1) && case "$a" in *"第 0 帧就要有水声"*"json: "*) true ;; *) false ;; esac \
+ && vh desk feedback "$t/p" --json | python3 -c "import json, sys; d = json.load(sys.stdin); sys.exit(0 if d['decisions'] == {'look': 'L2'} and d['project'] == 'p' else 1)" \
+ && ok "desk feedback prints the latest submission (text and --json)" || bad "desk feedback: $a"
+ # a submission made while no watcher runs (after a timeout, say) is handed over by the next wait at once, and only once;
+ # what desk feedback printed counts as handed over too
+ a=$(python3 "$t/client.py" "$url" "$t/p" post "提前到的一条" && vh desk wait "$t/p" --timeout 20 2>&1); rc=$?
+ vh desk wait "$t/p" --timeout 1 >/dev/null 2>&1; i=$?
+ case "$rc:$i:$a" in 0:3:*"提前到的一条"*) ok "desk wait hands over a submission made before it was armed, at once and only once" ;;
+ *) bad "desk wait with a submission already there: rc $rc, then $i: $(printf '%s' "$a" | head -c 300)" ;; esac
+ a=$(python3 "$t/client.py" "$url" "$t/p" post "第三条" && vh desk feedback "$t/p")
+ vh desk wait "$t/p" --timeout 1 >/dev/null 2>&1; rc=$?
+ case "$rc:$a" in 3:*"第三条"*) ok "desk feedback counts what it printed as handed over" ;; *) bad "desk feedback then wait: rc $rc: $(printf '%s' "$a" | head -c 300)" ;; esac
+ # a copy of the project carries the running server's .desk.json and a .listening that names another folder: neither counts
+ cp -R "$t/p" "$t/p2" && printf '{"pid": %s, "since": 0, "until": 99999999999, "project": "%s"}\n' "$$" "$t/p" > "$t/p2/out/review/feedback/.listening"
+ if desk_start "$t/p2" "$t/srv2.log"; then
+ a=$(python3 "$t/client.py" "$desk_url" "$t/p2" status)
+ [ "$a" = False ] && ok "desk: a copied project starts its own server, and a .listening from another folder doesn't count" || bad "desk on a copy: listening said '$a'"
+ kill "$desk_pid"; wait "$desk_pid" 2>/dev/null
+ fi
+ a=$(vh desk "$t/p" --port 0 2>&1); case "$a" in *"already serving p"*) ok "desk: a second start points to the running server" ;; *) bad "desk second start: $a" ;; esac
+ kill "$pid"; wait "$pid" 2>/dev/null
+ [ ! -e "$t/p/out/review/.desk.json" ] && ok "desk: stopping the server removes out/review/.desk.json" || bad "desk left .desk.json behind"
+ vh desk wait "$t/p" --timeout 1 > /dev/null 2>&1; rc=$?; [ $rc = 3 ] && ok "desk wait exits 3 on timeout" || bad "desk wait --timeout 1 exited $rc"
+ # the language: bin/vh new --lang writes the BRIEF line; bin/vh review takes it when the gate JSON has no lang
+ if OVH_PROJECTS="$t/new" vh new math ci-lang --lang en >/dev/null 2>&1 && grep -q '^- Review language: en ' "$t"/new/*-ci-lang/BRIEF.md \
+ && OVH_PROJECTS="$t/new" vh new math ci-zh >/dev/null 2>&1 && grep -q '^- Review language: zh ' "$t"/new/*-ci-zh/BRIEF.md; then
+ vh new math ci-x --lang fr >/dev/null 2>&1; rc=$?; [ $rc = 1 ] && ok "new --lang writes Review language (default zh) and rejects fr" || bad "new --lang fr exited $rc"
+ else bad "new --lang: Review language not written"; fi
+ cp -R tools/desk/fixtures/zh "$t/rz" && cp -R tools/desk/fixtures/zh "$t/re" && sed 's/^- Review language: zh /- Review language: en /' tools/desk/fixtures/zh/BRIEF.md > "$t/re/BRIEF.md"
+ sed 's/^{/{"lang": "en",/' tools/desk/fixtures/zh/out/review/gate-1.json > "$t/rz/out/review/gate-1b.json"
+ if vh review "$t/rz" 1 >/dev/null 2>&1 && vh review "$t/re" 1 >/dev/null 2>&1 && vh review "$t/rz" 1b >/dev/null 2>&1 \
+ && grep -q '' "$t/rz/out/review/gate-1.html" && grep -q '' "$t/re/out/review/gate-1.html" \
+ && grep -q '' "$t/rz/out/review/gate-1b.html"
+ then ok "review: the gate JSON's lang, else the BRIEF's Review language (the desk's rule), and the desk's extra keys pass"; else bad "review language rule"; fi
+ rm -rf "$t"
+}
+
case "${1:-all}" in
- --smoke) smoke_checks; decision_checks ;;
+ --smoke) smoke_checks; decision_checks; desk_checks ;;
--committed) # exactly what a push would send: HEAD in a clean temporary checkout, uncommitted changes left out
w="$(mktemp -d "${TMPDIR:-/tmp}/vh-ci.XXXXXX")/head"; git worktree add -q --detach "$w" HEAD || exit 2
(cd "$w" && tools/ci.sh); rc=$?; git worktree remove --force "$w"; rmdir "$(dirname "$w")"; exit $rc ;;
- all) static_checks; doc_checks; smoke_checks; decision_checks ;;
+ all) static_checks; doc_checks; smoke_checks; decision_checks; desk_checks ;;
*) echo "usage: tools/ci.sh [--smoke | --committed]"; exit 2 ;;
esac
[ $fails = 0 ] && ok "all checks passed" || printf '\033[31m%s check(s) failed\033[0m\n' "$fails"
diff --git a/tools/desk/README.md b/tools/desk/README.md
new file mode 100644
index 0000000..7f70a10
--- /dev/null
+++ b/tools/desk/README.md
@@ -0,0 +1,144 @@
+# 审阅台(bin/vh desk)
+
+审阅台是一个本地网页:人在页面里逐条勾选"可以 / 要改 / 疑问"、写一句、提交;agent 在后台等着,人一提交就接着做。它是 `studio` 档位默认的审阅界面,`standard` 想用也可以(什么时候用、agent 怎么挂等待,见 `playbook/01-pipeline.md` 的"审阅台")。这一页讲它读什么、写什么,也就是项目文件要怎么写,审阅台才排得出来。
+
+**文件是唯一的事实来源。** 审阅台每次刷新都从项目文件重新读,自己不存状态;人的草稿只留在浏览器里,提交以后才落进项目。读不出结构的文档不会被藏起来:每份文档都在"文档"页原样排版,结构化的页面空着时,就地显示那份文档的原文,"文档"页顶上列出哪些没按约定读出来。
+
+## 命令
+
+```bash
+bin/vh desk <项目> [--port N] # 起服务,只听 127.0.0.1;默认 8780–8799 里第一个空闲端口;这个项目已经有一个在跑,就只打印它的地址
+bin/vh desk wait <项目> [--timeout 秒] # 等人提交:打印反馈,退出码 0;超时退出码 3(默认 7100 s)
+bin/vh desk feedback <项目> [--round 1b] [--json] # 打印最近一次提交,并记为"已交给 agent"
+bin/vh desk data <项目> # 审阅台从项目里读出了什么(JSON),排查"为什么这一页是空的"时用
+```
+
+只用 Python 标准库,不联网,页面不从 CDN 拉任何东西,浅色和深色都有,界面有中文和英文。
+
+## 读什么
+
+表格按表头的**开头几个字**认列(中英文都行,英文不分大小写),列的顺序随意,多出来的列不管。
+
+### BRIEF.md
+
+| 位置 | 读什么 | 英文项目 |
+|---|---|---|
+| 第一行 `# BRIEF:片名` | 片名 | `# BRIEF: title` |
+| `## Spec` 的 `- 键: 值` | 全部列出;`Effort`、`Review language`(`zh` / `en`,界面语言;这一轮的 gate JSON 写了 `lang` 时以它为准,和 `bin/vh review` 同一条规则)、`Output`、`Watch on` 放在立意卡上 | 同左 |
+| `## Content` 的 `- 键: 值` | `Concept (one line)`、`Spine (one line)`、`Recurring motif`、`What the viewer should know/feel at the end`;`Specifics` 下面缩进的几条 | 同左 |
+| `## Outline`(或 `## 大纲`)里的表 | 每段一行,见下 | `## Outline` |
+
+大纲表的列:
+
+| 列(开头是) | 用途 | 英文 |
+|---|---|---|
+| `#` | 段号;不写就按顺序编 | `#` |
+| `段落` | **段名,按内容或阶段起**("按下发送""信道编码与调制") | `Segment` |
+| `标签` | 可选的小标签,起承转合、幕之类,显示在段名旁边的小框里 | `Tag` |
+| `环节` / `阶段` | 可选,这一段讲的环节 | `Stage` |
+| `时间` | `0–10 s`、`1:30–1:52`;时间条按它画 | `Time` |
+| `小节` | `4–7`;没有"时间"列时,用配乐的节拍表换成秒 | `Bars` |
+| `观众` | 观众看完知道 / 感到什么 | `Viewer`、`Takeaway` |
+| `关键画面` / `画面` | 关键画面 | `Key visual`、`Visual` |
+
+旧写法的段名"承 · 发出去"会拆成标签"承"和段名"发出去"。
+
+### SCRIPT.md
+
+第一张带 `字幕`(字幕稿)或 `旁白`(旁白稿)列的表。
+
+| 列(开头是) | 用途 | 英文 |
+|---|---|---|
+| `#` | 条目编号,点评按它记 | `#`、`ID` |
+| `段` / `段落` | 属于大纲哪一段:写段名或段号;对不上时按时间归段 | `Segment` |
+| `时间` 或 `小节` | 同大纲 | `Time`、`Bars` |
+| `术语` | 可选,术语卡上的词(`video-types/02` 的"术语卡") | `Term` |
+| `字幕` / `旁白` | 正文;读速按"这一条占的时间"粗算 | `Caption`、`On-screen`、`Narration`、`VO` |
+| `画面` | 画面 | `Visual` |
+| `素材` | NOTES 素材清单的编号(`#2 #4`) | `Facts`、`Source` |
+
+### NOTES.md
+
+- `## 素材清单` 里的表:`#`、`素材`、`类别`、`上屏说法`(可选)、`出处`。不再用的素材整格划掉 `~~…~~`(老写法"(关卡 ① 后不用)……"也认),事实核对页把它淡化、不给点评。英文:`## Material`,列 `#`、`Item`、`Kind`、`On screen`、`Source`。
+- `## 待核实的事实` 里的 `- [ ]` / `- [x]`。英文:`## To verify`。
+
+### DECISIONS.md
+
+- 表头以 `决定` 开头的表:`决定`、`谁拍板`、`状态`、`在哪看`(导演模式的账本)。英文:`Decision`、`Who`、`Status`、`Where`。
+- `## agent 定的事` 下的每条 `- 日期 话题 · 定了什么 — 理由:… — 翻案:…`,中间可以有 `###` 小标题(显示成分组)。**作废的整行划掉**,后面写原因:`- ~~2026-10-03 style · 夜色画面 — 理由:…~~(关卡 ① 后作废)`,审阅台把它收进"已经作废的",不再给"翻案"按钮。英文:`## Decided by the agent`,`— why: … — undo: …`。
+
+### REVIEW.md
+
+每个 `## ` 小节是一轮:`- 原话(说明):"……"` 是人的原话,`- 定了:…`、`- 改了:…`(同一行写 `定了:A 改了:B` 也行)是之后的处理。只有空占位的小节(模板里还没填的关卡 ②③)和模板最后两节参考文字不算历史。标题以"暂停"开头、又没写"恢复"的小节,让顶栏显示"暂停"。英文:`- Verbatim: "…"`、`- Decided: …`、`- Changed: …`,`Paused`。
+
+### shots.json
+
+和 `bin/vh storyboard` 同一份(格式见 `bin/vh storyboard -h`):一个列表,或 `{"video", "note", "shots": [...]}`;每镜 `id`、`start`、`end`、`segment`、`label`、`reads`、`caption`、`transition`、`unsure`、`frame`。关键帧取 `frame`,没有就取 `bin/vh storyboard` 写的 `out/check/storyboard/frames/.png`。分镜页播放的视频:`shots.json` 的 `video`,否则最新审阅页的 `animatic`,否则 `out/animatic.mp4`、`out/draft.mp4`。
+
+### out/review/gate-.json
+
+和 `bin/vh review` 是同一份文件,字段见 `playbook/01-pipeline.md` 的"审阅页"(`include` 照样生效)。"这一轮"是 `bin/vh review <项目> ` 最后做的那一页:它每次把页名写进 `out/review/.current`,审阅台和 `bin/vh desk wait` 都先读它。所以关卡 ② 之后回头出一页立意 `1b`、关卡 ③ 之后重定配音出 `E3b`、自己起名的 `final`,都会是"这一轮"。`.current` 没有,或者它指的那一页已经删了,才按流程顺序取最后一页:先按站(1、E0、E1、2、E2、E3、E4、3、E5),同一站再按页(1、1b、1c……,不分大小写);不合这个写法的页名排在最前,彼此之间看修改时间。复制项目、碰一下旧文件,都不会改变哪一页是"这一轮"。`bin/vh review <项目>` 不写页名时,渲染的也是这一轮;要出新的一页,写上它的名字。页名是 1–24 个字母、数字、`-` 或 `_`,按磁盘上文件本来的写法算(APFS 不分大小写:写 `1D` 找到的是 `gate-1d.json`,这一页就叫 `1d`);链接到项目外面的 gate 文件不读。审阅台另外认几个可选字段,`bin/vh review` 不管它们:
+
+```json
+{
+ "listen": [{"t": 9.4, "note": "17 个字飞出手机:每个字一个八音盒的音"}],
+ "music": {"audio": "audio/film_mix.wav", "beats": "audio/film.beats.json", "roll": "audio/film.roll/overview.png",
+ "labels": {"type": "打字", "send": "发送"}},
+ "film": "out/review/open-F2.mp4"
+}
+```
+
+- `listen`:声音页的"请你听这几处",`t` 是秒。
+- `music`:声音页播哪条音轨、读哪份节拍表、乐段在色块上叫什么(`labels` 把乐段的 `name` 换成人看得懂的名字)。不写时,读 `audio/` 里最新的 `*.beats.json`,音轨取同名的 `*_mix.wav`,没有再取 `*.wav`,谱面取同名的 `.roll/overview.png`。节拍表也用来把"小节"换成秒。
+- `film`:逐秒点评页放哪支视频;不写就用 `assets` 里第一支视频。时长从视频本身读。
+
+路径都相对项目目录;审阅台只给项目目录里的文件(不给隐藏文件,不出项目目录)。
+
+## 写什么
+
+提交后,服务按这个顺序写:
+
+1. `out/review/feedback/<轮>-.md`:一条一行的文字版,人的话原样不动;
+2. `REVIEW.md`:插一节 `## 审阅台反馈 · 关卡 <轮> · <日期 时间>`,把上面的文字整段原样放进代码块,下面留一行"定了: 改了:"给 agent 填;插在模板的"授权跳过怎么记"之前,没有这一节就接在文末;
+3. `out/review/feedback/<轮>-.json`:最后写,原子替换。`bin/vh desk wait` 等的就是它。同一秒里的第二份、第三份加 `-2`、`-3`,按数字排。
+
+交给 agent 的提交记在 `out/review/feedback/.consumed`:`bin/vh desk wait` 打印过的、`bin/vh desk feedback` 打印过的,都算交过了。`wait` 一启动,先看这一轮有没有还没交过、又晚于这一页 gate JSON 的提交(比如上一次 `wait` 超时之后人才提交),有就立刻打印、退出 0;没有才开始等新的。`.listening`(有 agent 在等)和 `.desk.json`(服务在跑)里都记着项目目录的完整路径,从别的项目拷来的这两个文件不算数。
+
+JSON 的格式:
+
+```json
+{
+ "round": "1c", "lang": "zh", "project": "2026-10-01-glass-of-water", "submitted_at": "2026-10-04T02:38:50",
+ "decisions": {"look": "F2"}, "defaulted": [],
+ "items": [
+ {"target": "seg:9", "status": "change", "label": "大纲 第 9 段 这一次", "text": "这个压根没有升华主题……"},
+ {"target": "cap:c11", "status": "change", "label": "字幕 c11", "text": "", "orig": "信号好,一次装 8 个;信号差,只装 2 个。", "suggest": "……"},
+ {"target": "time:12.4", "status": "ask", "label": "小样 0:12.4", "text": "这里看不清", "t": 12.4}
+ ],
+ "general": "大体可以"
+}
+```
+
+- `decisions`:这一页每个决定选了哪个选项;`defaulted` 列出人没动、按推荐算的。
+- `items[].status`:`ok` 可以、`change` 要改、`ask` 疑问。没标的条目都算通过。
+- `items[].target` 的前缀说明点评的是什么:`dec:` 决定、`least:` 没把握的、`deleg:` 替人定的(gate JSON `delegated` 的序号)、`concept:line` 立意、`seg:` 大纲段、`cap:` 字幕或旁白条、`shot:` 镜头、`fact:` 素材、`time:` 小样的某一秒、`atime:` 音轨的某一秒、`led:` DECISIONS 里 agent 定的事(序号)、`doc:<文件>#<小节>` 文档的某一节。
+- `suggest` 是人直接写的"改成",`orig` 是原句。
+
+服务会检查:轮次对得上一份 `gate-<轮>.json`(还没有审阅页时用 `notes`),决定和选项都在那一页里,每个字段的类型、状态和 target 合法,单条文字不超过 4000 字、整份不超过 256 KiB,正文和 Content-Length 对得上;不合的回 400,不会断开连接。
+
+## 安全
+
+- 只绑定 127.0.0.1,只回答 Host 是自己的请求(挡住 DNS rebinding)。
+- 提交要带页面里那一次启动生成的令牌、JSON 格式,浏览器带了 Origin 时必须是同源;所以同一个浏览器里别的网页既读不到项目,也没法冒充人提交反馈。
+- `/p/` 下的每个文件都带 `Content-Security-Policy: sandbox allow-scripts allow-popups`(没有 `allow-same-origin`):项目里的 HTML、SVG(HyperFrames 的 `index.html`、`node_modules` 里的页面、下载来的参考图)在浏览器里打开时是一个不透明的源,脚本照样能跑,但读不到审阅台的页面和令牌,也没法替人提交。
+- `/p/` 只给项目目录里的普通文件:不出目录、不跟着符号链接出去;隐藏文件和 `*.env` 不给,按请求的名字和解析后的真实文件各查一遍,不分大小写(指向 `.env` 的链接、`keys.ENV` 都不给)。
+- 每个连接 30 s 不动就断开,发了一半的请求不会一直占着线程。
+
+## 限制
+
+- 开着的旧页面不会自己换轮:agent 出了新的一页,人在之前打开、没刷新的标签页里提交,反馈记在旧的那一轮(那一页的 gate JSON 还在,服务照收)。页面右上角的 ↻ 重新读取。
+- 几个 `bin/vh desk` 进程同时动手(比如两个 `bin/vh desk wait` 一起启动,或者一边 `wait` 一边 `desk feedback`),最坏是同一份反馈交出去两次:它们各自先看到这份反馈还没交过,各打印一遍。不会崩,也不会丢反馈:每次写文件都用自己的临时文件再原子替换,`.consumed` 的读改写在 `.consumed.lock` 上加了文件锁(flock),谁都不会把别人记下的名字冲掉。
+
+## 测试
+
+`tools/ci.sh` 用 `tools/desk/fixtures/` 里的两个小项目(中文、英文)测读取(包括读不出表格时退回原文、"这一轮"取 `.current`(2 之后的 1b、3 之后的 E3b、自起的页名,`.current` 指向已删的页时退回流程顺序)、语言规则、超长和深层嵌套的 markdown),在空闲端口起一个服务测 `/api/data`、206 Range、`/p/` 的 sandbox、各种拒绝和坏请求、提交到临时副本、REVIEW.md 的追加,测 `wait` 在有提交时退出 0、启动前就到的提交立刻交出、交过的不再交,拷来的 `.desk.json` 和 `.listening` 不算数,以及 `bin/vh desk -h`。
diff --git a/tools/desk/desk.py b/tools/desk/desk.py
new file mode 100644
index 0000000..75c4703
--- /dev/null
+++ b/tools/desk/desk.py
@@ -0,0 +1,74 @@
+"""bin/vh desk: the review desk, a local page where the reviewer ticks, comments and submits each stop.
+
+ bin/vh desk [--port N] serve the desk (127.0.0.1; default: the first free port of 8780–8799;
+ --port 0: any free port). Already serving this project: print its URL.
+ bin/vh desk wait [--timeout s] block until the reviewer submits, print the feedback, exit 0 (3 on
+ timeout; default 7100 s). An agent runs it in the background after each
+ stop: its exit is the wake-up call. A submission for the current page that
+ came in before it was armed, and was never handed over, is printed at once.
+ bin/vh desk feedback [--round r] [--json] print the latest submission (exit 1 when there is none); it then
+ counts as handed over (out/review/feedback/.consumed)
+ bin/vh desk data print the data the desk shows, as JSON (what reader.py makes of the files)
+
+The desk reads the same out/review/gate-.json as bin/vh review, plus the project's markdown (the contract is in
+tools/desk/README.md). A submission goes to out/review/feedback/-