docs: bilingual pair for the session-modes RFC

The zh.md counterpart (section-for-section mirror per the i18n contract:
identical heading structure, byte-identical text fences, same link targets)
plus the recorded i18n.yaml and the language-switcher lines on both sides —
matching the approval / sandbox / env-state RFC practice.
This commit is contained in:
kingwl
2026-07-07 15:55:36 +08:00
parent 1c2d51c9ca
commit 61847183ad
3 changed files with 169 additions and 0 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-07-session-modes-plan-mode.md: f07f032791a97dc1954edf00ff9bd911d1834eb8
2026-07-07-session-modes-plan-mode.zh.md: a97e4d4f72edb63016e853f6858f9f856b2cd89e
@@ -2,6 +2,8 @@
Status: proposed
English | [中文](2026-07-07-session-modes-plan-mode.zh.md)
## Problem
The harness has no way to put an agent into a reduced-authority working state. The canonical feature that needs one is plan mode — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority only through an explicit approval. [The extension cookbook](../../../cookbook/extension-cookbook.md) already reserves the row ("Plan mode — `tools/pre-execute` (deny writes) + a mode prompt section"), and [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records session modes as a known gap both reference adapters ship (Claude's plan auto-mode, Codex's read-only / agent / full-access presets). Neither says where the mode STATE lives, how it survives resume and fork, or how its model-visible consequences stay honest with the session log.
@@ -0,0 +1,161 @@
# RFC: 会话模式——plan mode 作为日志化的每-agent 策略态
Status: proposed
[English](2026-07-07-session-modes-plan-mode.md) | 中文
## 问题
harness 目前没有办法把一个 agent 置入低权限的工作状态。最需要这个能力的经典 feature 就是 plan mode——agent 在只读工具策略下探索与设计,产出一份可评审的计划,只有经过一次显式批准才跨回完整权限。[扩展 cookbook](../../../cookbook/extension-cookbook.md) 已经预留了这一行(「Plan mode——`tools/pre-execute`deny writes+ 一个模式提示 section」),[ACP 功能矩阵](../../../../packages/ui/acp/acp-feature-support.md) 把会话模式记录为两个参照 adapter 都已发布的已知缺口(Claude 的 plan 自动模式、Codex 的 read-only / agent / full-access 预设)。但两处都没有说:模式**状态**存在哪里,它如何在 resume 与 fork 之间存活,它对模型可见的后果如何与会话日志保持诚实。
对已发布 plan mode 的调研(Claude Code、Cursor、Copilot、OpenCode、Gemini CLI、Cline、Windsurf、Codex)显示出处处相同的五个组成部分:低权限工具策略、计划工件、审批时刻、执行态切换、持久状态。其中四个在本仓库已经以带门禁的基础设施形态存在:模型「被告知能做什么」在每个 step 由 [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) 塑形,实际发出的内容以 `request/header*` 事件记入日志([可重构性](../../implemented/architecture/2026-07-05-reconstructable-requests.md));「什么能真正运行」由 `tools/pre-execute` 以类型化决定把关([拦截 seam](../../implemented/feature/2026-06-30-interception-seams.md));审批时刻就是 `ask` 词汇,由审批 seam 服务(`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`,写作本文时在 `feat/sandbox-support` 分支在途——合入后改为链接);持久的每-agent 事实是 `SessionEventMap` 成员([`todo/write` 先例](../../implemented/feature/2026-06-29-todo-write-tool.md))。缺失的第五个就是模式本身:一个命名的、持久的、策略 listener 能读取的每-agent 策略状态。
把模式留给约定的生态展示了要避开的失败形态。Pi 式的模式扩展会争抢一份 last-wins 的全局激活工具列表,仅靠 prompt 文本执行「只读」(对仍然注册着的工具的幻觉调用照样执行),并且为了在 compaction(历史压缩)后存活而把计划状态重注入每一个请求。这些洞在这里都能结构性地关闭——但前提是模式是日志化的会话状态,而不是插件私有内存。
## 提案
**会话模式(session mode)**是一个命名的、日志化的、每-agent 的策略状态。模式定义——哪些工具保持可见、渲染哪段指导 section——是部署配置;对某个 agent **生效中**的模式则是会话状态,从它的日志 fold 出来。一个新的 product 包 `@deepseek-ai/dsh-mode`(位于 `packages/mode/mode/`,新顶层组,`packages/approval/` 的形态)拥有事件词汇、一个薄薄的 `ctx.modes` 服务和全部策略 listenerloop 不改。harness 只内置一个定义:`plan`
### 模式状态是一条会话事件
`dsh-mode`**`mode/set`** 声明合并进 `SessionEventMap`log-only、非 surface 的事件,携带 `{ mode: string }`,整值替换语义同 `todo/write`。纯函数 `foldMode(events)` 返回生效中的模式——最后一条 `mode/set`,一条都没有则为默认模式——插件用惰性游标按会话缓存这个 fold(`foldRequestHeader` 的习语)。因为事件是 log-only 的,它永不进入模型 transcript;因为它不是 surface 节点,compaction 永远遮蔽不了它:无论活跃会话、resume 还是 fork,fold 看到的都是完整日志。按[事件域语义](../../implemented/architecture/2026-06-30-event-domain-semantics.md),日志就是事实通道,所以模式状态不需要任何实时 `agent/*` 镜像——UI 从 `session/event` 上读 `mode/set`
默认模式就是策略的缺席:没有 section、没有过滤、没有闸门。一个从未见过 `mode/set` 的 agent,行为与从未加载 `dsh-mode` 的部署逐字节相同——这让所有既有快照 goldens 保持稳定,也让这个插件可以无条件进入任何组合。
### 两层执行
**软层——模型看见什么。**一个 `system-prompt/assemble` waterfall(瀑布式事件)listener 读取调用方 agent 的模式(`AssembleContext` 携带 `agent`),在 plan 模式下把 `assembly.tools` 过滤到该模式的 allowlist(白名单)并追加该模式的指导 section。loop 本来就每 step 渲染并把结果记账:进入或离开一个模式在下一个 step 表现为一条 `request/header-delta`,于是每次模式转换都是可归因、可 diff 的日志事实,[可重构性](../../implemented/architecture/2026-07-05-reconstructable-requests.md)不变量靠构造保持常绿。section 按模式静态,计划本身留在对话里(消息与工具参数,本就在上下文中),所以模式不带来逐 step 的 prompt 抖动——Pi 式「每个请求重注入计划文件」的 hack 在这里没有必要,只会白烧前缀缓存。
**硬层——什么能运行。**一个 `tools/pre-execute` listener 对 allowlist 之外的任何调用 deny,理由文本点名当前模式并把模型引回规划。这一层与过滤器并不冗余:[`ToolRegistry.execute()`](../../../../packages/core/tools/README.md) 按名字分发任何已注册工具,模型幻觉调用一个被过滤掉的(或 MCP 注册的)工具,没有闸门照样会执行。对着 allowlist 的 deny-by-default 也让两层互为掩护——某个兄弟 `assemble` listener 把 schema 集合重新放宽,也无法让放宽的工具变得可执行。无 agent 的执行(没有可 fold 的会话)直接放行,与审批 seam 的无 agent 降级一致。
### 模式切换与 turn 封闭
翻转模式的写者有两个。**工具**`exit_plan_mode`)在自身执行内部追加 `mode/set`——天然被 turn 封闭,即 `todo/write` 的路径。**用户**经 `ctx.modes.set(agent, mode)` 翻转(stdio 命令、ACP `session/set_mode`),而这条路径不能立即追加:[每条会话事件都被 turn 封闭](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),空闲的 agent 没有敞开的 turn。因此服务先记下一个待落账意图(pending intent),在下一个 `turn/start` 之后作为第一条追加落账。时序保证了它对本 turn 发出的请求是正确的:loop 在 turn 打开之后、每个 step 之前组装 prompt,所以 `turn/start` 处的落账会被 step 1 的组装 fold 到,而 turn 中途的翻转落在下一个边界、于下一个 step 生效——与所有被调研产品「对后续请求生效」的语义一致。用户翻转还会被**叙述**:当落账的模式与最后一条 `request/header` 处的 fold 不同,服务在同一帧内追加一条合并后的通知(「The user switched this session to plan mode.」),因此净值为零的翻转序列什么也不说,工具驱动的退出改由它自己的工具结果叙述,首个 turn 之前设定的模式也不叙述(section 就是状态陈述)——这是在途 env-state 提案(`docs/rfc/proposed/feature/2026-07-06-env-state-visibility.md`)钉下的边界叙述原则:被静默翻转的 prompt 面会让 transcript 继续用 header 已经不再持有的状态说话。代价诚实且有界:空闲时设置的待落账意图,若进程在下一个 turn 前死亡即丢失(设置它的 UI 手里仍有状态,重新应用即可);把用户翻转升格为空闲时的持久事实需要一个泛化的空闲记录原语,在损失被证明真实之前不进范围。
### 计划工件与退出工具
模型侧的 **`exit_plan_mode`** 工具收拢闭环,仅在 plan 模式可见(assemble 过滤器在那里加入它、在别处丢弃它;pre-execute 闸门在 plan 模式之外 deny 它)。它唯一的参数就是计划文本——这让计划成为骑在普通 `tool/call` 事件上的持久、可回放日志工件,无需发明一个会漂移的平行计划文件存储。它的[渲染意图](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)在设计期定死:`generic` 调用卡以计划的第一个标题为题、计划 markdown 为内容,结果卡也是 `generic`。审批时刻不是新机器:模式闸门只对这一个调用返回 `ask`,审批 seam 负责路由(ACP`session/request_permission` 挂到已流出的调用上,一次性 allow/reject),`allowed-once` 让工具体把 `mode/set` 追加回默认模式,其余任何结局都变成告诉模型继续规划的纠正性 `isError`。批准之后的执行跟踪已由 `todo_write` 覆盖。没有组合任何 answerer 的部署保持安全但手动的形态:闸门的 `ask` 解析为 `unavailable` 并 deny(seam 的失败关闭默认),退出退化为用户手动切换模式——绝不会退化为未经批准的退出。
### 包形态
`dsh-mode` 是一个 product 包,不是 capability-seam 三件套——没有可替换的实现;可变的部分是配置值和固定的 listener([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md):不要抢先拆分;审批 seam 做了同样的判断)。它比 [fs-policy 式](../../../../packages/fs/fs-policy/README.md)的纯事件闸门插件多出一点,只因为 UI 需要一个调用面:`ctx.modes` 暴露 `list()`(配置的定义集,给模式选择器)、`get(agent)`fold 加上任何待落账意图)与 `set(agent, mode)`(对配置校验、记录意图、在边界落账)。其余一切都通过 listener 参与,所以卸载这个包是优雅地失去模式,而不是弄坏某个消费者。
模式定义是经校验的插件 Config——按仓库惯例(从 `cordis.yml` 可改、无需改代码):每个定义给出工具 allowlist 和 section 文本,`plan` 内置的默认 allowlist 是只读面(`read``todo_write``web_search`/`web_fetch``exit_plan_mode`),`bash``subagent` 被排除,直到沙盒家族真能约束它们。`AgentOptions` 可声明合并,所以 `dsh-mode` 声明一个可选 `mode` 字段:创建者(或转发父模式的 subagent provider)为子代理播种初始模式,经同一条待落账路径在第一个 turn 应用。
### 协议与 UI 表面
stdio 应用获得模式切换命令、一行 banner,以及审批 waterfall 上的 readline answerer,退出审批就在终端里提问(user-interaction stdio provider 在场时骑它的「一次一个提示拥有 stdin」队列——yes/no 确认就是退化的单选——否则用裸 readline)。在 ACP 上,模式**选择器**是本包的表面:`session/new`/`session/load``ctx.modes` 通告 `availableModes`/`currentModeId`(经 `ctx.get` 伺机消费,即 `tool-bash` 模式),`session/set_mode` 调用 `set()` 并乐观地通知 `current_mode_update`(待落账模式就是用户的选择;日志化的 `mode/set` 随后在边界落地),一个 `session/event` listener 对每条与上次通知不同的日志翻转再通知一次。各个环境旋钮——沙盒模式、审批策略、模型——**不是**模式:它们属于 `session/set_config_option`,而在途 env-state 提案的 config 阶段草图目前把 `set_mode` 接到环境事实上,这是两份提案之间**唯一**的重叠——此处提议的分界是选择器归模式 / 旋钮归 config options,模式定义将来可以捆绑环境事实(在 `ctx.envState` 在场时顺带应用),让 Codex 式预设仍是单个模式,后合入的提案修正自己的接线以对齐。退出工具的审批完全不需要新的 ACP 工作——它骑审批 seam 的 answerer。
## 详细设计
### 词汇
```text
'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface,
// whole-value replace — the last one in the log wins
DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable
```
载荷不携带 reason/来源字段:工具驱动的翻转紧邻它的 `tool/call`,用户翻转坐在它的 turn 边界上,因果就在日志相邻处——与[可重构性 RFC](../../implemented/architecture/2026-07-05-reconstructable-requests.md)对 header delta 做出的「叙事字段可推导」同一判断(在途的 `env/state` 事件携带 `source`,恰因它的 drift 变体**没有**日志相邻的因——是对照,不是冲突)。模式名是配置声明的词汇,不是跨边界的不透明 id,所以保持裸字符串(不用 `Branded<B>`)。
### 配置与 resolve 步骤
```text
interface ModeDefinition { section: string; tools: string[] } // prompt text; allowlist of tool NAMES
interface ModeConfig { modes?: Record<string, ModeDefinition> } // plan's built-in definition merged unless overridden
resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud:
// 'default' as a key rejected; allowlists may name
// not-yet-registered tools (registration is dynamic)
```
allowlist 刻意是未来按工具决定映射(`allow | deny | ask`)的退化形式:执行期的 ask 策略(「每次写都问」的 guarded 模式)推迟到审批 seam 长出持久授权(`allow_always`——它自己的开放问题)之后,而配置形状必须在它们到来时无需迁移。
### fold、服务与落账
`foldMode(events)` 是纯函数(导出给重构器与测试);服务用 `WeakMap<Session, { cursor, mode }>` 里的惰性游标按会话跟踪它——每次读取 O(新事件数),永不失效,因为日志仅追加且 `mode/set` 不是 surface 节点(compaction 改写不了它)。`ctx.modes`cordis Service,键 `modes`)暴露 `list()`——合成的 `default` 条目加上配置的定义集,给选择器——`get(agent): { current, pending? }`,以及 `set(agent, mode)`:对配置校验名字、丢弃 no-op(目标等于 pending ?? current)、否则把意图记进 `WeakMap<Session, string>`。一个被收容的 `session/event` listener[防御模式](../../../defensive-patterns.md):策略插件不得杀死事件流)在下一个 `turn/start``step/end` 把待落账意图作为 `mode/set` 追加落账——两处都在 step 的工具执行窗口之外,所以一个 step 的执行永远运行在其组装所 fold 的模式下——并且当落账模式与最后一条 `request/header` 处的 fold 不同时,在同一帧内追加那条合并的 `context/message` 通知。播种骑 `agent/created`:声明合并的 `AgentOptions.mode` 变成待落账意图,于是显式选项在 create 与 resume 上都压过日志基线——与调用配置种子相同的优先级——而 fork 子代理完全不需要机制(父的 `mode/set` 就在种子前缀里)。
### 软层:计算型 section 与 post-`next()` 过滤器
指导 section 是一个普通注册的 section`{ name: 'mode:policy', order: 50, text: context => … }`——order 50 位于 persona0)之后、工具指南(100–199)之前;它解析为 fold 出的模式的配置文本,对默认模式或无 agent 的组装解析为 `''`(渲染时丢弃)。工具过滤器是一个包裹式的 `system-prompt/assemble` waterfall listener:先 await `next()`,再过滤**返回的** assembly 的 `tools`,于是其包裹之内任何位置的添加都被覆盖。过滤器在所有模式下执行一条规则:`exit_plan_mode` 可见当且仅当 agent fold 出的模式是 `plan`——这也正是让默认模式的组装与无 `dsh-mode` 部署逐字节相同的原因,即便该工具始终注册着。在非默认模式下它再与该模式的 allowlist 求交。
### 硬层:闸门
```text
tools/pre-execute: no exec.agent → next() // agent-less calls have no session to fold
folded mode = default → next()
exec.name = exit_plan_mode:
plan mode → { kind: 'ask' } // the approval moment; the registry routes it
otherwise → deny
allowlisted → next()
otherwise → deny // reason names the mode and points at exit_plan_mode
```
闸门只 fold **已落账**的模式,绝不看待落账意图——执法依据与请求 header 出厂时的状态相同。因为 `ask` 在这里产生、由 `ToolRegistry.execute()``ctx.approval` 解析,`dsh-mode` 对审批包零依赖;没有该 seam 的部署得到注册表的失败关闭降级。
### `exit_plan_mode`
`defineTool`,一个必填参数 `plan: string``execute` 拒绝无 agent 调用([`todo_write` 先例](../../implemented/feature/2026-06-29-todo-write-tool.md)),复查 fold 模式作纵深防御,turn 内追加 `mode/set { mode: 'default' }`,返回一句简短确认;下一个 step 的组装恢复完整工具集并记账变宽的 `request/header-delta``presentCall` 是携带计划 markdown 为内容的 `generic` 卡——审批提示按 `callId` 挂到这张已流出的调用卡上,人批准的正是日志里的工件。驳回以注册表的「user rejected」`isError` 到达模型,模型修订后重新提交。
### 依赖与接入面
`dsh-mode` 以 peer 依赖 `cordis``dsh-session``dsh-agent``dsh-tools``dsh-system-prompt`manifest 形状镜像 `dsh-tool-todo`),inject `['tools', 'systemPrompt']`,既不依赖审批包也不依赖任何 UI。stdio 应用加一个 `/mode [name]` 行处理分支(打印或切换 + banner,绝不发给模型)和为它自己的 agent 服务的 readline answerer。ACP 线上映射钉在「协议与 UI 表面」;包层面 bridge 对 `dsh-mode` 只取 type-only 的 peer 边并伺机读取服务,没有该插件的 bridge 行为与今天完全一致。
### 录制场景与 harness op
`input.json` 增加一个 step op`{ "op": "setMode", "mode": "plan" }`,经真实的 `session/set_mode` RPC 驱动。`plan-mode` 场景:initialize → newSession → setMode(plan) → 一个「探索并试图 `write`」的 prompt(被闸门 deny,逐字钉住)→ 模型经 `exit_plan_mode` 提交计划 → 脚本化 `permissionAnswers` 批准 → 后续 prompt 真正写入。因为模式在 turn 1 之前设定,**首个** `request/header` 快照就已经是 plan 形态(过滤后的工具 + sectionreason `initial`)——变宽的 delta 出现在退出处;场景把这两者连同 `mode/set` 对一起钉住。姊妹场景 `plan-mode-reject` 脚本化驳回并钉住纠正结果。两者都需要带 key 的录制会话;deny/驳回文案在此之前先在单测层逐字钉死(审批 RFC 的同一立场)。
### 机械尾巴
没有声明任何新的 cordis 事件(`mode/set``session/event`listener 挂在既有 waterfall 上),所以事件 catalog 不动;同一变更中再生成:持久化日志 catalog(`mode/set`)、服务 catalog`ctx.modes`JSDoc 完备)、配置 catalog`ModeConfig`)、工具 catalog`exit_plan_mode`)、生产者/消费者图与文档图、模块图。仓库管线:根 tsconfig `paths` 条目、新组 README 加 [packages 总表](../../../../packages/README.md)一行(新顶层组正是该表点名的深思熟虑之举)、`architecture.md` 的 capability-services 表为 `ctx.modes` 加一行(受预算门禁约束)、cookbook 行升级。
## 路线图
plan mode 是一个 feature,就作为一个整体落地。一个能被锁进规划态、却没有正当途径提议离开的 agent 不是这个 feature 的缩小版——而是另一个更糟的东西:每份计划都以模型请求用户去拨一个它看不见的开关收场。因此下面两个阶段是一次堆叠落地的构建与评审顺序([堆叠评审指南](../../../cookbook/responding-to-pr-review-on-a-stack.md)):阶段 2 叠在阶段 1 上,整叠一起合入;任何一个阶段都不是可独立交付的里程碑。
唯一的硬前置是审批 seam`docs/rfc/proposed/feature/2026-07-06-approval-seam.md`):退出审批就是它的 `ask` 路由端到端。它已在 `feat/sandbox-support` 上实现,所以耦合是合入顺序,不是未建的工作——本栈在它落 master 之前基于该分支。更广的在途邻域是收敛而非冲突:sandbox-escalation 分支带来第一个真实的审批组合(它的示例与脚本化应答 harness 是我们录制场景遵循的先例),env-state 提案为环境事实钉下同一套 fold-from-log + 边界应用习语(它的 `session/set_mode` config 阶段草图是唯一协调点,已在「协议与 UI 表面」解决),user-interaction seam 在场时为 stdio answerer 提供 stdin 纪律。
### 阶段 1——模式内核
`dsh-mode` 包:`mode/set` + `foldMode`、assemble 过滤器与模式 section、pre-execute 闸门、带 `plan` 定义的经校验 Config、带待落账机制的 `ctx.modes``AgentOptions.mode` 合并,以及 stdio 切换命令。计划期点名的覆盖:单测层覆盖 fold、过滤器、闸门矩阵、落账时序与合并边界通知;一个快照场景把 `mode/set` 连同随之的 `request/header-delta` 钉进 `session.jsonl`;所有既有 goldens 逐字节不变(默认模式不可见)。文档尾巴同阶段落地:包 + 组 README、[packages 总表](../../../../packages/README.md)一行、再生成的持久化/配置 catalog、cookbook 的 plan-mode 行从草图升级为包指针。
### 阶段 2——退出闭环与协议面
`exit_plan_mode`(ask 门控、携带计划、渲染意图如上)、stdio readline answerer,以及 ACP 会话模式映射(`session/set_mode``current_mode_update`、通告可用模式)。覆盖:单测层覆盖 ask 路由与两条结局路径以及 stdio answerer;一个经脚本化 `permissionAnswers` 驱动批准与驳回的录制快照场景;bridge 协议测试里的 ACP 模式往返。
推迟到本次落地之外、各自独立决策:经转发 `AgentOptions.mode` 的 subagent 模式继承(选项字段本身随阶段 1 落地)、模式定义内的按工具 `ask` 策略(OpenCode 式「plan 模式里 bash 要问」)、`plan` 之外的预设模式(read-only、accept-edits)、plan 模式中沙盒背书的 bash 约束,以及待落账丢失被证明真实时的空闲记录原语。
## 备选方案
**以权限模式为概念(Claude Code 形态)。**一个融合审批策略与工具策略的 `permissionMode`。在这里它们是两条轴、两个所有者:审批 seam 拥有「谁来回答这个问题」,模式拥有「模型得到什么表面」。ACP 把它们建模为相关但不同(模式将来可以选择审批策略——模式定义加一个字段,而非合并两个概念)。
**capability-seam 三件套。**接口/实现/消费者适合可替换的后端;模式的可变部分是配置值,不是实现。拆分只会制造一个空的实现包——与审批 seam 和 [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) 相同的「不要抢先拆分」判断。
**loop 拥有模式状态。**按常规规则拒绝(plugins, not loop changes):这个 feature 需要的每个挂点——assemble、pre-execute、turn 边界、会话事件——都已是成文 seam,改 loop 除了耦合什么也买不到。
**纯 prompt 的 plan mode(无硬闸门)。**Pi 的失败形态:过滤 schema(或好言相劝)拦不住对仍注册工具的分发。pre-execute 闸门是执行层;过滤器是 UX 与缓存卫生。
**仅运行时的模式(UI 或 bridge 本地、不入日志)。**resume 与 fork 会静默丢掉模式,模式引起的 header delta 在日志里失去可归因的因。日志化状态正是模式免费获得可审计与可恢复的原因。
**经 `agent.inject()``context/message` 承载模式翻转。**复用了现成的 turn 封闭路径,却把策略状态放进模型 transcript——模型不需要被告知两次(section 已经在说),log-only 的事实不应占据 surface。
**计划文件存储(`.plans/` 目录)。**为日志已能可回放地携带的东西建第二个持久之家;想要文件的部署可以后加一个写文件的工具。一个事实一个家。
**用布尔 `planMode` 而不是命名模式。**对仓库已经跟踪的表面太窄(ACP 通告的是模式**列表**;Codex 发布三个),日后泛化还要重命名事件词汇。通用机制现在零额外成本;只有 `plan` 作为定义发布。
**工具策略栈服务(对 Pi 批评的对症药)。**专门的工具策略组合服务为时过早:waterfall listener 靠构造可组合,deny-by-default 硬闸门让过滤顺序竞争无法被利用。真冲突出现再机制化。
**用散文或 steering 退出而不是工具。**没有工件也没有审批时刻——工具的参数就是可评审的计划,它的 `ask` 才给了人一个挂在确切转换上的结构化是/否。
## 验收标准
- 生效中的模式是会话日志的纯函数:resume 与 fork 零额外机制地恢复它,`mode/set` 之后下一个 step 跟着匹配的 `request/header-delta`dev 不变量全程常绿。
- 用户驱动的翻转在下一个边界恰好叙述一次,净值为零的翻转序列不叙述;工具驱动的退出只经它的工具结果叙述。
- 默认模式下插件不可见:加载与不加载 `dsh-mode` 的组装逐字节相同,所有既有快照 goldens 不变。
- plan 模式下过滤后的 schema 与模式 section 同时到达线上请求与日志化 header;对已注册但被过滤的变更工具的调用在 `tools/pre-execute` 被 deny,理由点名模式。
- 模式定义(allowlist、section 文本)从 `cordis.yml` 可改、无需改代码;未知模式名在 `set()` 时大声报错。
- `exit_plan_mode` 的批准路径翻转模式并在下一个 step 恢复完整工具集;驳回路径返回纠正性 `isError` 并停留在 plan 模式;两者都由经脚本化权限应答的录制快照场景钉住;ACP `session/set_mode` 往返更新 `current_mode_update`stdio answerer 在终端里提问。
- 文档尾巴随落地一起交付:README、再生成的 catalog(持久化日志、配置、cordis 服务)、cookbook 行。
## 风险
空闲时设置的待落账用户翻转在进程于下一个 turn 前死亡时丢失——接受(UI 重新应用;空闲记录原语是实践中真咬人时的逃生口)。每次模式转换都是一次日志化的 header 变化、因而是 provider 侧的一次前缀缓存重置——固有、在逐 step 用量中可见,是反对「模式反复横跳的 UI」的论据,不是反对本设计的。兄弟 listener 顺序不确定,包裹在模式 listener **之外**的外部 assemble listener 可能把过滤后的 schema 重新放宽——过滤器作用于 `next()` 返回的 assembly(其包裹之内全部覆盖),硬闸门让任何被放宽的东西不可执行;残余代价是表面性的(模型看见一个用不了的工具),接受而不机制化。plan 模式内置 allowlist 排除 `bash``subagent`,在沙盒家族与模式继承落地之前确实损失探索力(没有 `git log`、没有只读委托)——接受该风险的部署今天就能在自己的配置里放宽。整个落地以审批 seam 先行合入为门——一次深思熟虑的进度耦合,取代单独交付模式内核(按路线图,那是不完整的 feature);该 seam 已在其分支上实现,本栈暂以它为基。没有组合任何 answerer 的部署保持安全但手动的 plan 模式(`ask``unavailable` → deny),模式 section 告诉模型经 `exit_plan_mode` 提交计划——被 deny 就请用户切换——所以它绝不会对着闸门反复冲撞。两份在途提案触及 ACP 模式表面(本文与 env-state 的 config 阶段):「协议与 UI 表面」中的选择器归模式 / 旋钮归 config options 分界是提议中的契约,合入顺序决定谁来接线 `session/set_mode`,后合入者负责修正。分支繁多的策略代码在 per-file 100% 覆盖门禁下是实打实的工作量,如 ACP bridge 一样照单接受。