【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
plannotator annotate与plannotator annotate-last通过--gate、--json、--hook三个标志,把原本面向人工的 Markdown 批注工具升级为带结构化输出的完整评审闸门(review gate):评审者可以显式批准、驳回或发送批注,而 stdout 上输出的单行 JSON 让 Hook、插件与自动化流水线无需解析自由文本即可路由决策。本文以 annotate-gates-and-json-responses.md 为主线,结合仓库源码(annotate-output.ts、strict-annotate-result.ts、cli.ts)与测试用例,完整讲解三个标志的 stdout 契约、严格闸门(--require-approval/--result-file)的退出码与原子发布语义,以及面向 spec 驱动开发、逐轮评审和程序化路由的实战配方。读完你可以在 Claude Code、Codex、Copilot CLI、Gemini CLI、OpenCode、Pi 等任何受支持 harness 上把 Plannotator 接入 PostToolUse/Stop Hook,做成 fail-closed 的机器可读评审环节。
三个标志如何协作:能力总览
plannotator annotate和plannotator annotate-last接受三个可组合标志,把批注行为扩展成完整的评审闸门:
--gate:在批注 UI 中增加一个Approve(批准)按钮。评审者在三种决策中选择:approve(批准)、send annotations(发送批注)、close(关闭)。--json:将每一次决策以结构化的 JSON 对象输出到 stdout,让 Hook 和插件可以基于决策类型路由,而不必解析自由文本。--hook:输出与 Claude Code 和 Codex 的 PostToolUse/Stop Hook 协议直接兼容的 hook 原生 JSON。隐含--gate,是 Hook 集成的推荐方式。
三个标志可以任意组合使用(单独用或一起用),并且在所有受支持 harness(Claude Code、Copilot CLI、Gemini CLI、OpenCode、Pi、Codex)上语义完全一致。CLI 用法面在 cli.ts 的annotate子命令帮助文本中有完整定义;从源码结构看,plannotator annotate-last与copilot-last共享同一组--gate/--json/--hook标志面(cli.ts)。
Stdout 契约:标志 × 决策 × 输出矩阵
原文档给出的 stdout 契约矩阵是理解整套语义的核心,完整保留如下:
Flags │ UX │ Approve │ Close │ Annotate ──────────────────────────┼──────────────────┼─────────────────────────┼──────────────────────────┼─────────────────────────────────────────────── (none) │ 2-button │ n/a │ empty │ feedback (plaintext) --gate │ 3-button │ `The user approved.` │ empty │ feedback (plaintext) --json │ 2-button │ n/a │ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} --gate --json │ 3-button │ {"decision":"approved","feedback":"..."}│ {"decision":"dismissed"}│ {"decision":"annotated","feedback":"..."} --hook │ 3-button │ empty │ empty │ {"decision":"block","reason":"..."}几个关键性质:
- 不带任何标志时是 2 按钮 UI(没有 Approve),Close 输出空、Send Annotations 输出纯文本反馈——这是历史行为,逐字节保持不变。
--gate单独使用时,Approve 在 stdout 上输出单行The user approved.,让模板和 Agent 无需--json也能区分「批准」与「关闭」;Close 不输出任何内容;Send Annotations 输出反馈 Markdown。--json与--gate正交:--json单独用保持 2 按钮 UI,只产生annotated和dismissed两种决策;--gate --json才解锁全部三种决策的结构化形式。- 每次调用只输出一行 JSON。一次调用、一个决策、stdout 上一行——这是 Hook 和插件可以稳定依赖的契约。
JSON schema
--json输出的对象遵循如下 schema:
{ "decision": "approved" | "annotated" | "dismissed", "feedback": "string (present for annotated decisions and approvals with notes)" }feedback字段出现在annotated决策中,以及「带备注批准」(--gate --json下)的approved决策中。从源码看,序列化逻辑由 strict-annotate-result.ts 的serializeStrictAnnotateResult与 annotate-output.ts 的formatAnnotateOutcome实现:approved仅在存在非空 feedback 时附带该字段(...(result.feedback ? { feedback: result.feedback } : {})),annotated总是携带 feedback(可为空字符串),dismissed永远只有{"decision":"dismissed"}。
示例输出
Approved(评审者点击 Approve,--gate --json):
{"decision":"approved"}如果评审者在批准的同时留下了备注,结构化传输会同时保留两者:
{"decision":"approved","feedback":"Keep the retry bounded."}Dismissed(评审者点击 Close,--json或--gate --json):
{"decision":"dismissed"}Annotated(评审者发送批注,--json或--gate --json)。feedback字段就是 Plannotator 在纯文本模式下输出的同一份 Markdown:
{ "decision": "annotated", "feedback": "# File Feedback\n\nI've reviewed this file and have 2 pieces of feedback:\n\n## 1. Remove this\n`the selected text`\n> I don't want this.\n\n## 2. Feedback on: \"some highlighted text\"\n> This needs more detail.\n\n---" }测试 annotate-output.test.ts 覆盖了上述全部字节级输出形态,包括 legacy 纯文本逐字节不变("The user approved.")、legacy hook 输出逐字节不变,以及「只有 gate 下的直接 JSON 批准才携带非空 feedback」的语义。
--gate:三向评审决策
--gate在批注 UI 的 Close 与 Send Annotations 之外增加一个 Approve 按钮,让评审者显式声明意图:
- Approve:产物本身已经足够好,Agent 应当继续执行。
- Send Annotations:评审者有具体修改意见,反馈原样返回。
- Close:会话在没有决策的情况下结束——既不是对 Agent 的信号,也不是一组指令。
纯文本模式下,Approve 在 stdout 上输出单行The user approved.,模板和 Agent 不借助--json也能区分批准与关闭;Close 不输出任何内容;Send Annotations 输出反馈 Markdown。若要做 Hook 集成,请改用--hook,它直接输出 hook 原生 JSON。--gate的 Approve 标记常量定义在 annotate-output.ts(APPROVED_PLAINTEXT_MARKER = "The user approved.")。
--json:结构化 stdout
--json把每次决策输出为带decision字段、可选feedback负载的 JSON 对象。需要显式路由的 Hook 和插件(把批准与驳回分开记日志、按决策类型做闸门判断、累积遥测数据)都用它。
--json与--gate正交的细节值得注意:
--json单独用保持 2 按钮 UI,只产生annotated和dismissed决策。--gate --json解锁全部三种决策的结构化形式。- 直接的
--gate --json批准可以携带 feedback。无法投递附注的传输(transport)会在丢弃 feedback 前发出警告,并引导评审者改用Send Feedback。 - 在 OpenCode 和 Pi 上,
--json被静默接受——这两个 harness 直接回写会话而不是走 stdout,因此该标志在那里不生效。配方保持可移植。
一个容易忽略的语义:--gate --json下「批准时带备注」是非阻塞的指导性意见,不是要求再来一轮修订;而 Send Annotations 语义上仍然是「修订后重新打开」(revise and reopen),不是「批准并继续」。两者都体现在 annotate-output.ts 的supportsAnnotateApprovalNotes谓词(gate && json && !hook)中。
--hook:hook 原生 JSON
--hook输出与 Claude Code 和 Codex 的 PostToolUse/Stop Hook 协议直接兼容的 hook 原生 JSON,并隐含--gate(始终是三按钮 UX)。若同时传入--hook和--json,--hook胜出(cli.ts 的annotate帮助文本中亦注明此优先级,见 annotate.md)。
决策到 stdout 的映射:
- Approve→ 空 stdout → hook 通过 → Agent 继续。
- Close→ 空 stdout → hook 通过 → Agent 继续。
- Send Annotations→
{"decision":"block","reason":"<feedback>"}→ hook 阻塞并携带反馈。
{"decision":"block","reason":"..."}正是 Claude Code 和 Codex 在 PostToolUse/Stop Hook 中的原生协议格式,因此不需要任何包装脚本。这正是 Hook 集成推荐--hook的原因。该输出逻辑在 annotate-output.ts 中实现:hook 模式下,approved或exit返回null(空 stdout),有 feedback 的批注输出{"decision":"block","reason":<feedback>},无 feedback 的批注同样返回null。
--hook被刻意保持原样:原生 hook 协议用空 stdout 表示批准,因此它没有通道携带批准备注。需要带备注时使用Send Feedback来阻塞——该动作语义仍是「修订后重新打开」,而非「批准并继续」。与--json同理,该标志在 OpenCode 和 Pi 上被静默接受,因为那些 harness 不使用 stdout 作为信号通道。
严格直接闸门(Strict Direct Gates)
对于 fail-closed 的直接 CLI 闸门,可以在--gate --json之上叠加两个严格选项:
plannotator annotate docs/plan.md --gate --json \ --require-approval \ --result-file .tmp/plan-review-result.json--require-approval:只有approved退出码为0。annotated和dismissed仍会先发布其合法的 JSON 决策,然后以非零码退出。--result-file <path>:以原子方式发布与 stdout 相同、以换行结尾的 JSON 字节。路径从调用工作目录解析(见 strict-annotate-result.ts 的resolveResultFilePath)。- 两个选项都要求
--gate --json,仅对直接的annotate调用可用,且不能与--hook组合;hook 的输出与退出行为不受影响。
参数解析在 cli.ts 的parseStrictAnnotateOptions中实现,测试 cli.test.ts 验证了:严格选项可以在目标路径前后任意位置出现、可以单独使用任一严格选项、缺少值或重复指定会报错,以及非annotate --gate --json组合(包括--hook并存)会被拒绝。
结果文件的原子发布语义
--result-file的发布保证是源码级可见的(strict-annotate-result.ts 的writeAnnotateResultFile):
- 结果文件的父目录必须已存在,且目标文件必须不存在(
assertResultPathAvailable会在目标已存在或父目录缺失时抛错,strict-annotate-result.ts)。 - Plannotator 在同一目录写一个私有
0600权限的临时文件(open(temporary, "wx", 0o600)),写入内容并追加换行后flush/close,再以原子的 no-clobber 硬链接发布(link(temporary, resultFile)),随后删除临时文件。它从不覆盖已存在的目标,也不会回退到非原子拷贝。因此每次调用都应使用唯一的 result 路径。 - stdout 决策记录先于结果文件写入。如果发布失败,评审者的决策已经输出到 stdout——即使同时传了
--result-file,也务必捕获 stdout。
两个关于发布保证的注意事项:
0600临时文件模式是 POSIX 权限,在 Windows 上实际无效;如果结果路径需要私有,请在 Windows 上使用文件系统 ACL。- 原子链接(与原子 rename 一样)之后不会对父目录执行
fsync。发布对并发读者是原子的,但紧接着发生的机器崩溃仍可能丢失目录项。
调用方侧行为:保持源码路径稳定
把被评审的源码放在稳定的项目路径上,这样修订与版本历史能持续指向同一产物;结果文件和诊断日志则可以放在范围受限的临时目录中。点击 Close 发布{"decision":"dismissed"}。
评审放弃(abandonment)的自动解析
放弃评审同样发布dismissed决策。本地直接结构化闸门会跟踪其连接的评审表面(review surface):一旦至少一个表面连接过,失去最后一个表面后开始30 秒重连宽限期,到期后闸门以dismissed解析。刷新页面、离开再返回、或关闭多个标签页中的某一个,都会重连或让另一个表面保持连接,因此这些操作都不会触发 dismiss。Approve、Send Annotations 和 Close 仍然优先于待处理的过期事件。
实现位于 packages/shared/annotate-client-lease.ts:ANNOTATE_CLIENT_LEASE_GRACE_MS = 30_000(第32行),心跳间隔ANNOTATE_CLIENT_LEASE_HEARTBEAT_MS = 5_000,SSE 路由为/api/annotate/client-lease。该追踪器是无依赖的「最后客户端断开检测器」:一旦最后一个客户端断开就启动宽限计时器,等待重连(如标签页刷新);从未连接过的标签页永远不触发过期——还没有任何东西可以被放弃。
被放弃的评审会保留其已保存的批注草稿,你写的内容不会丢失。如果过期解析后某个陈旧标签页又回来了,它的 Approve / Send Annotations / Close 会报错而不是假装生效——调用方收到的决策才是算数的。
两种情形属于调用方侧恢复,永远不会自动转为批准:
- 从未有评审客户端连接的会话(浏览器启动失败会一直等待,所以请自行传入启动超时);
- 半开传输丢失(half-open transport loss),连接只有通过失败的 heartbeat 写入才能被证明已死,察觉时间可能超过宽限期。
远程与共享会话完全关闭该行为,因为隧道或代理断开不算放弃。该判定由 annotate-output.ts 的supportsAnnotateClientLease谓词统一给出:只有gate && json && !hook && !isRemote的本地直接结构化闸门才启用;测试 annotate-output.test.ts 逐一验证了关闭路径,另一个测试则扫描startAnnotateServer({的全部调用点,强制每个调用点都通过该共享谓词而非硬编码布尔值。
主要使用场景
Spec 驱动开发框架
spec-kit、kiro、openspec 这类 spec 驱动开发框架每个特性会生成多个 Markdown 产物:spec.md、plan.md、tasks.md、research.md、data-model.md,各自经历 clarify、review、approve 循环。Plannotator 的批注 UI 正好契合这类产物的评审:对 Markdown 的行内、定向反馈正是这些工作流需要的。
配合--gate,在 Write 上挂一个 PostToolUse hook,Agent 每次产出 spec 产物就触发一次完整评审闸门。评审者批准、批注或驳回,Agent 相应继续、修订或跳过。
逐轮评审(Turn-by-turn review)
把plannotator annotate-last --gate接入 Claude Code 的 Stop hook,每个 Agent 回合暂停等待人工评审:Approve 干净地结束该回合;Send Annotations 用评审者的反馈重新提示 Agent;Close 结束回合而不注入任何内容。
程序化决策路由
当 hook 或插件需要区分批准与驳回时,--json提供单行、稳定的契约。一次性决策变成机器可读事件——无需解析 stdout、没有脆弱性。
Hook 集成配方速览
--hook是 Hook 集成的推荐方式:Approve/Close 输出空 stdout(hook 通过、Agent 继续),Send Annotations 输出{"decision":"block","reason":"<feedback>"}(hook 阻塞并展示反馈)。无需包装脚本。
Recipe 1 — 评审 Agent 写下的每个文件(spec 驱动框架的核心模式)。加入.claude/hooks.json(或其他 Agent 的等价文件):
{ "hooks": { "PostToolUse": [ { "matcher": "Write", "hooks": [ { "type": "command", "command": "plannotator annotate \"$CLAUDE_TOOL_INPUT_file_path\" --hook", "timeout": 345600 } ] } ] } }timeout是 4 天(秒)。hook 会在评审者在浏览器中工作期间阻塞,所以务必设高。
Recipe 2 — 评审每个 Agent 回合(Stop hook):
{ "hooks": { "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "plannotator annotate-last --hook", "timeout": 345600 } ] } ] } }Send Annotations 阻止 Agent 停止并用反馈重新提示;Approve 或 Close 让回合正常结束。两者可组合:PostToolUse hook 闸门单个文件写入,Stop hook 闸门整个回合。
hook 触发时,Agent 会把工具输入暴露为环境变量——Claude Code 用$CLAUDE_TOOL_INPUT_file_path/$CLAUDE_PROJECT_DIR,Codex 用$CODEX_TOOL_INPUT_file_path/$CODEX_PROJECT_DIR,请按你的 Agent 替换。更多可复制配方(含 OpenCode 与 Pi 的可移植变体)见 hook-integration.md。
退出码契约:grep 惯例
默认情况下,每种决策都退出0——现有纯文本、JSON 和 hook 集成均保持不变。使用--require-approval后,只有approved退出0;annotated和dismissed先发布其 JSON 结果,再以1退出。
严格调用中,配置错误或无法启动的调用退出2:错误的标志组合、无效的--result-file目标,以及所有 annotate 启动失败(路径缺失或不可读、URL 不可达、空文件夹、歧义文件名、文件过大)。这些启动失败在非严格调用中一如既往地退出1;而在严格标志下1的含义是「评审者未批准」,因此拼错的路径绝不能上报为驳回。
原子发布失败也退出2,但该代码的含义是结果文件未发布——评审者的决策已经写入 stdout。只有 stdout 写入失败才会完全不留记录。
整体遵循 grep 惯例:0= 批准,1= 未批准,2= 闸门本身出错。该退出码契约与实现细节在 strict-annotate-result.ts 中有完整定义:STRICT_GATE_ERROR_EXIT_CODE = 2(第25行)、isStrictAnnotateInvocation(严格调用判定,第43-47行)、annotateStartupFailureExitCode(启动失败在严格调用下升为2,第57-61行)、annotateOutcomeExitCode(requireApproval && !approved → 1,第79-84行)。端到端测试 annotate-cli.test.ts 通过真实进程 spawn 验证了:非严格单 token 失败保持退出1、严格闸门绕过容错参数解析(自然语言参数在严格调用下退出2且 stdout 为空)、--tailscale发布失败在严格调用下退出2且不产生结果文件。
落地建议
- Hook 集成默认用
--hook:协议原生、零包装脚本;需要把决策当事件记录或做条件路由时再加--json类消费方。 - CI/流水线 fail-closed 闸门用
--require-approval --result-file:记住退出码语义(0/1/2对应 grep 惯例),为每次调用使用唯一 result 路径,并同时捕获 stdout。 - OpenCode/Pi 上
--json与--hook被静默接受(harness 直接写回会话而非 stdout),--gate行为在所有 harness 上一致——配方保持可移植。 - 相关参考:标志完整矩阵见 annotate.md(含
/api/plan、/api/feedback、/api/approve、/api/exit等服务端 API 与PLANNOTATOR_JINA、JINA_API_KEY环境变量);可复制 Hook 配方见 hook-integration.md。
【免费下载链接】plannotator
Annotate and review coding agent plans and code diffs visually, share with your team, send feedback to agents with one click.
相关推荐
Plannotator Hook 集成:用 --hook 把人工评审闸门嵌入 Agent 生命周期
Plannotator Hook 集成:用 hook 把人工评审闸门嵌入 Agent 生命周期 本篇基于 Plannotator 仓库中的官方指南 hook i
plannotator OpenCode 插件 /plannotator-annotate 命令解析:从 Markdown 存根到注释反馈闭环
plannotator OpenCode 插件 /plannotator annotate 命令解析:从 Markdown 存根到注释反馈闭环 本篇以 plan
Plannotator review 命令深度解析:从 Skill 接入到浏览器式代码评审与反馈回传
Plannotator review 命令深度解析:从 Skill 接入到浏览器式代码评审与反馈回传 当 coding agent 在本地生成大量代码改动时,在
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考