11 KiB
Proposal M7: CEO 模式状态机化(resolveCeoMode 单一真源)
来源:问题.md M7(行 260-264) 状态:proposed(依赖 B5 + C4) 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs) Owner: TBD 估计工时:1.5 周 Blocking dependencies:B5(eval harness)、C4(initial-user-turn 精简)
问题陈述
CEO 当前处于哪个模式,由两处独立逻辑各自决定:
位置 1:src/main/prompt-builder/ceo-modes/ 下 5 个 markdown 文件
awaiting-hire.en.mdawaiting-org.en.mdoperating.en.mdproactive-footer.en.mdteam-building.en.md
加载逻辑在 runtime 靠字符串匹配——某段代码根据 company/CEO 状态推出一个 mode 名,然后按文件名拼路径读 markdown。
位置 2:src/main/orchestration/initial-user-turn.ts:62-83
- 独立判断 CEO 是否 "zero active reports"
- 独立判断是否"awaiting hire approval"
- 独立决定发什么 cue
两处都在决定 "CEO 现在该做什么",互相不知道对方存在。结果是:ceo-modes 文件可能说当前是 operating 模式(有团队在运行),而 initial-user-turn 同时决定发 "your team is empty" 的 cue——内部打架。
根因分析
Linus 视角:同一件事(CEO 当前状态)被两个代码位置各自计算——这是第一性的违规。状态应该是单一 source of truth。
当前设计把 "mode" 当成一个魔法字符串在 prompt-builder 和 initial-user-turn 之间传递(甚至不是显式传,而是两边各自推),没有一个函数说"this is where CEO mode is decided"。
加载 markdown 的路径也是隐式约定——ceo-modes/team-building.en.md 的命名约定没有 schema 保证,漏一个文件、写错一个 mode 名、加新 mode 时忘了建对应文件,都是静默失败。
Karpathy 视角:模式分支是 agent 行为最关键的决策点——CEO 在 team-building / operating / awaiting-* 下的应答完全不同。这种分支必须集中并有 eval 覆盖每个分支。
设计方案
单一 resolveCeoMode 函数
新建 src/main/orchestration/ceo-mode.ts:
export type CeoMode =
| "team_building" // 无 active report,需要 hire
| "awaiting_hire" // 已提交 hire approval,等人批
| "awaiting_org" // 组织架构待确认
| "operating" // 正常运行态
;
export interface CeoModeContext {
agent: AgentRecord;
company: CompanyRecord;
activeAgents: AgentRecord[];
pendingApprovals: ApprovalRecord[];
executionMode: ExecutionMode;
}
export function resolveCeoMode(ctx: CeoModeContext): CeoMode {
if (ctx.agent.role !== "ceo") {
throw new Error("resolveCeoMode called on non-CEO agent");
}
const activeReports = ctx.activeAgents.filter(a => a.ceoId === ctx.agent.id && a.status !== "terminated");
const pendingHires = ctx.pendingApprovals.filter(a => a.kind === "hire_agent");
if (activeReports.length === 0 && pendingHires.length === 0) {
return "team_building";
}
if (activeReports.length === 0 && pendingHires.length > 0) {
return "awaiting_hire";
}
if (ctx.company.orgStatus === "pending_review") {
return "awaiting_org";
}
return "operating";
}
所有判断 CEO 模式的代码都必须调这个函数。
Prompt-builder 从 mode 映射到片段
// src/main/prompt-builder/ceo-modes/index.ts(新增)
const modeToSlug: Record<CeoMode, string> = {
team_building: "team-building",
awaiting_hire: "awaiting-hire",
awaiting_org: "awaiting-org",
operating: "operating",
};
export function loadCeoModePrompt(mode: CeoMode, locale: AppLocale): string {
const slug = modeToSlug[mode];
// 加载 `ceo-modes/{slug}.{locale}.md`
}
通过 TypeScript 枚举保证 mode 和 markdown 文件的一一对应——加新 mode 必须加枚举 + 对应 markdown,编译期强制。
initial-user-turn 走 resolveCeoMode
src/main/orchestration/initial-user-turn.ts 不再重复判断 CEO 状态,调 resolveCeoMode(ctx):
const mode = resolveCeoMode(modeCtx);
const cue = getCeoModeCue(mode, trigger);
这样 prompt-builder 里的 markdown 和 initial-user-turn 的 cue 通过同一个 mode 驱动——永远一致。
proactive-footer 归类
当前的 proactive-footer.en.md 不是一个 mode——它是所有 CEO 模式共享的 footer。应该:
- 重命名目录结构:
ceo-modes/{mode}.md专门装 mode-specific - 另起
ceo-shared/proactive-footer.md放共享片段 - 避免把 footer 误当作第 5 个模式
Migration 计划
- Phase 0(前置):B5 eval harness + C4 完成
- Phase 1(2 天):实现
resolveCeoMode函数 + 单测(不动 call site) - Phase 2(3 天):prompt-builder 从 mode enum 加载 markdown,删除字符串拼接
- Phase 3(3 天):initial-user-turn 重构——删除自行判断逻辑,全走
resolveCeoMode - Phase 4(3 天):eval 对比——相同 CEO 状态下行为一致性
- Phase 5(1 天):重组目录——proactive-footer 从 ceo-modes 移出
风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|---|---|---|
| 模式分类漏 corner case | awaiting_hire 和 awaiting_org 同时成立 | resolveCeoMode 函数里明确 priority + 单测覆盖 |
| eval 发现行为偏移 | 新 mode decision 逻辑和旧隐式判断不等价 | 保留旧 initial-user-turn 分支 flag,对比 N 轮 run |
| Markdown 内容过期 | enum 加了新 mode 但没写 markdown | 加载时 assert 文件存在,启动 fail fast |
| Locale 缺失 | awaiting-hire.zh.md 不存在但 locale=zh |
fallback 到 .en.md + telemetry 告警 |
验证标准
- Typecheck 保证完整性:新增 mode 必须同时更新 enum + modeToSlug map + markdown 文件,否则编译 fail
- Eval 行为对比:每个 mode 至少 3 种代表性 company 状态,新旧行为 99% 一致
- Call site 归一:
grep -rn "team_building\|awaiting_hire\|operating" src/main/只在ceo-mode.ts里出现枚举定义,其他地方通过 import 使用 - Markdown coverage:所有 mode 在所有支持的 locale 下都有文件,启动时校验通过
- Initial-user-turn 简化:
initial-user-turn.ts行数降低(假设从 159 行 → < 100 行)
不在范围
- 不重写 ceo-modes 的 prompt 内容:只是让加载机制严谨,具体文字走 Owner 业务决策
- 不加新 mode:当前 4 个 mode 足够(proactive-footer 不是 mode)
- 不改 non-CEO agent 的模式逻辑:他们没有这种 mode 概念
- 不做 mode transition 审计 log:有需要另开 event 表
- 不做 mode-specific tool gating:tool 可用性和 mode 解耦(不同 mode 可用相同 tool,只是 prompt 引导不同)
参考文件清单
src/main/prompt-builder/ceo-modes/awaiting-hire.en.mdsrc/main/prompt-builder/ceo-modes/awaiting-org.en.mdsrc/main/prompt-builder/ceo-modes/operating.en.mdsrc/main/prompt-builder/ceo-modes/proactive-footer.en.md(将重组)src/main/prompt-builder/ceo-modes/team-building.en.mdsrc/main/orchestration/initial-user-turn.ts:62-83(重复逻辑)src/main/prompt-builder/system-prompt.ts(mode 注入点)src/main/database/agents.ts(CEO 状态 query)src/main/database/approvals.ts(pending hire approval query)
附:为什么现在修
C4 已经把 initial-user-turn 的"实现细节泄漏"修了——如果不顺便做 M7,就会出现 initial-user-turn 变干净了但还在自行判断 mode 的状态。一次做完,两件事共享同一个 resolveCeoMode 函数,改 prompt 的团队只需要 reason about 一个真源。
M7 也是未来扩展 CEO 行为的基础——如果要加 "awaiting_funding" / "pivoting" 之类的新模式,没有 state machine 就是继续堆字符串判断。
附:gating matrix
| Gate | 条件 | 放行判断人 |
|---|---|---|
| G1 | B5 eval harness 可 record CEO 在各 mode 下的 baseline 行为 | Karpathy |
| G2 | resolveCeoMode 单测覆盖 4 种 mode × 边缘状态 |
Linus |
| G3 | prompt-builder 通过 enum 加载 markdown,启动时验证文件存在 | Owner |
| G4 | initial-user-turn 重构后行为 eval 与 baseline 一致 | Karpathy |
| G5 | proactive-footer 从 ceo-modes 目录移出,路径调整不破坏现有 prompt | Owner |
附:state machine 扩展指南
未来加新 mode 的最小改动点:
src/main/orchestration/ceo-mode.ts的CeoModeenum 增加成员resolveCeoMode函数加对应 if 分支ceo-modes/<new-mode>.en.md(+ 其他 locale)新建modeToSlug映射加一行- 对新 mode 写单测(至少 1 个 positive + 1 个 negative case)
TypeScript 会强制 1-4 的完整性——漏一步编译 fail。
附:反模式对比
Before(initial-user-turn.ts:62-83 + ceo-modes/*.md 两处各自判断):
// 位置 1: initial-user-turn.ts
if (activeReports.length === 0) {
return teamBuildingCue;
}
// 位置 2: prompt-builder/system-prompt.ts (加载 ceo-modes)
const modeFile = loadCeoModeFile(company, agent); // 内部逻辑不一致
After(单一真源):
const mode = resolveCeoMode(ctx);
const cue = getCeoModeCue(mode, trigger);
const modeSection = loadCeoModePrompt(mode, locale);
两处都 derive from mode,保证一致。
附:为什么不选其他方案
方案 A(当前 proposal):enum + resolve 函数 + markdown 映射。
方案 B(把 mode 存 DB):不必要——mode 完全由当前状态 derive,存 DB 反而要维护一致性。
方案 C(把 markdown 内容放代码里):失去给产品经理 / 业务 owner 迭代 prompt 的低成本路径。
方案 A 保留 markdown 灵活性同时让加载逻辑类型安全。
附:mode priority 排序规则
resolveCeoMode 判断顺序(先匹配即返回):
team_building:无 active report + 无 pending hireawaiting_hire:无 active report + 有 pending hireawaiting_org:有 active report + org 待 reviewoperating:默认
顺序基于"外部依赖度"——团队空时必须先 team_building,其次考虑组织结构审查,最后才是运行态。单测覆盖所有 transition:
| from → to | 条件 | 覆盖点 |
|---|---|---|
| team_building → awaiting_hire | 提交 hire approval | unit |
| awaiting_hire → team_building | hire approval 被拒 | unit |
| awaiting_hire → operating | hire approval 通过 | unit |
| operating → awaiting_org | org review 触发 | unit |
| awaiting_org → operating | org review 通过 | unit |
附:proactive-footer 重组说明
ceo-modes/proactive-footer.en.md 当前存在于 ceo-modes 目录但不是 mode。重组:
Before:
ceo-modes/
├── awaiting-hire.en.md
├── awaiting-org.en.md
├── operating.en.md
├── proactive-footer.en.md ← 伪 mode
└── team-building.en.md
After:
ceo-modes/ ← 真 modes
├── awaiting-hire.en.md
├── awaiting-org.en.md
├── operating.en.md
└── team-building.en.md
ceo-shared/ ← 跨 mode 共享片段
└── proactive-footer.en.md
system-prompt.ts 加载 mode 片段后,独立拼接 ceo-shared/proactive-footer.en.md。