omo-codex 计划自动续跑引擎解析:ulw-execute-continuation Stop Hook 的架构、状态机与安全边界
本篇文章围绕 omo-codex 插件组件 ulw-execute-continuation(代码包名 @code-yeongyu/codex-ulw-execute-continuation)展开,剖析它如何作为 Codex Stop 钩子,在 ulw-execute 多轮计划执行中自动续跑:当一轮回复结束后,钩子读取 Boulder 状态与计划清单,向 Codex 返回 {"decision":"block"} 并注入续跑指令,直到顶层复选框全部勾选、最终门禁通过。读完本文,你将掌握该组件的完整运行链路——从 .omo/boulder.json 状态解析、计划清单统计、指令渲染到上下文压力抑制与外部阻塞逃生舱——并理解其全部静默返回场景与工程约束,可直接用于排查续跑失效或自行实现同类"长计划自动续跑"钩子。
组件定位:ulw-execute 计划执行链路中的"续跑引擎"
ulw-execute-continuation 是 omo-codex 插件 内专为 ulw-execute 技能设计的续跑注入器(continuation injector)。其核心职责可以用一句话概括:在 Codex 每一轮 Stop 事件触发时,判断"这个计划是否还没干完",若没干完就返回 block 决策并附上一条最新的续跑指令,驱动模型自动进入下一轮。
该组件与 ulw-execute 技能形成配套关系:技能负责创建计划、写入 .omo/boulder.json 并在 session_ids 中使用 codex: 前缀标识 Codex 会话;本组件只负责读取这些状态并决定"是否继续"。完整的行为契约定义在 组件 README 与 AGENTS.md 中,二者共同构成了仓库内最权威的功能说明。
整个决策循环是:
Codex 结束一轮回复
│
▼
Stop 钩子触发(hooks.json 注册)
│
▼
读取 .omo/boulder.json + 活跃计划 + transcript
│
▼
有未完成顶层任务且无阻断信号?
│
┌────┴────┐
▼ ▼
block+指令 静默(无输出)
(继续一轮) (本轮正常收尾)
核心契约:Stop 事件如何被翻译成"继续干"指令
钩子的对外输出形式极其简洁——一行 JSON:
{"decision":"block","reason":"<directive>"}
其中 decision 固定为 "block",reason 则是把 directive.md 模板中的占位符替换为当前计划状态后得到的完整续跑指令。该模板在每次调用时由 src/directive.ts 通过 readFileSync(new URL("../directive.md", import.meta.url)) 动态加载,而不是编译期内联,这保证了仓库维护者可以在不重新构建的情况下调整续跑指令。
从 src/codex-hook.ts 的实现可以看到 runStopHook 的完整判定顺序,任一条件命中即返回空字符串(无输出):
export function runStopHook(input: unknown, fs: ReadonlyFileSystem): string {
if (!isStopInput(input)) return "";
if (input.stop_hook_active) return "";
if (hasAllowedExternalBlockerMarker(input.last_assistant_message)) return "";
if (transcriptHasContextPressureMarker(input.transcript_path, fs)) return "";
const state = readContinuationState(input.cwd, input.session_id);
if (state === null) return "";
return JSON.stringify({
decision: "block",
reason: renderDirective(state, input.session_id),
} satisfies StopHookOutput);
}
StopInput 的字段定义在 src/types.ts:hook_event_name(仅接受 "Stop")、session_id、turn_id、transcript_path、cwd、model、permission_mode、stop_hook_active(布尔),以及可选的 last_assistant_message。注意 isStopInput 的运行时类型守卫相当严格,任何字段类型不匹配都会让钩子静默退出——这正是"绝不让钩子因畸形输入阻塞一轮 Codex 回合"约束的实现方式。
状态判定:boulder.json 读取与四种静默场景
状态读取由 src/boulder-reader.ts 负责,入口是 readContinuationState(cwd, sessionId)(见 #L37-L57)。它从 cwd/.omo/boulder.json 读取 Boulder 状态,按会话 ID 解析活跃工作项,再读取对应计划文件并解析清单。readContinuationState 在以下四种情况下返回 null,钩子随之静默:
boulder.json缺失或不可解析:readBoulderState内所有JSON.parse与结构校验失败都会返回null,而不会抛出异常导致钩子崩溃;- 没有任何工作匹配当前会话:
getWorkForSession在works中按session_ids(前缀codex:或opencode:,裸 ID 视为opencode:)匹配,并从多个匹配项中挑选updated_at/started_at最新的一项;仅在非 map 结构的旧式(legacy mirror)Boulder 文件里才回退匹配顶层mirrorWork; - 工作状态不可续跑:只有
active与paused可以继续,completed与abandoned都会停止续跑——对应源码isContinuableStatus(#L174-L176); - 计划清单总数为 0:
checklist.total === 0视为无可读的顶层清单,直接返回null。
该组件还支持任务级 git worktree:当 Boulder 工作项带有 worktree_path 时,resolveBoulderPlanPathForWork 会把计划路径重定向到 worktree 内部(若该路径真实存在),保证"计划文件本身也在工作树内编辑"这一约束落地。
真实状态的样例见测试夹具 boulder-single-codex-work.json,它同时展示了新版 map 结构与旧式 mirror 结构的混合形态:
{
"schema_version": 2,
"active_work_id": "work_1",
"works": {
"work_1": {
"work_id": "work_1",
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
"plan_name": "launch-plan",
"status": "active",
"started_at": "2026-05-28T00:00:00Z",
"session_ids": ["codex:sess_abc"]
}
},
"active_plan": "/repo/.omo/plans/plan-with-unchecked.md",
"plan_name": "legacy-launch-plan",
"started_at": "2026-05-28T00:00:00Z",
"status": "active",
"session_ids": ["codex:sess_legacy"]
}
计划清单解析:只统计"该数的"复选框
续跑与否最终取决于计划文件里还剩多少未完成任务。解析逻辑在 src/plan-checklist.ts,对外暴露 PlanChecklist 类型与 getPlanChecklist / parsePlanChecklist 两个函数。PlanChecklist 包含四个字段:
| 字段 | 含义 |
|---|---|
completed | 已勾选(- [x] / - [X])的顶层任务数 |
remaining | 未勾选(- [ ])的顶层任务数 |
total | completed + remaining |
nextTaskLabel | 第一个未完成任务的标签;全部完成时为 null(渲染为 "none (final gate pending)") |
统计规则(务必注意,这是最容易踩坑的地方):只有以下两个章节下的**列 0(column-0)**复选框会被计数:
## TODOs(匹配- [x] 1. <title>这类带数字编号的行,正则^- \[([ xX])\] ([1-9]\d*\. .+)$)## Final Verification Wave(匹配- [x] F1. <title>这类F<number>.前缀行,正则^- \[([ xX])\] (F[1-9]\d*\. .+)$,大小写不敏感)
而 ### Acceptance Criteria、### Evidence、### Definition of Done 等子章节下的嵌套复选框一律忽略。解析器还做了两件健壮性处理:
- 跳过 fenced code block:解析过程中维护一个围栏状态机(
parseOpeningFence/isClosingFence),代码块内的- [ ]不会被误计; - 尊重章节边界:
^#{1,2}命中的#/##标题会切换当前章节状态,保证## TODOs之外的复选框不进入统计; - 结构化回退:如果整个文档不存在上述两个结构化章节,则回退到"简单顶层清单"模式,统计全文列 0 的
- [ ]/- [x]复选框(SIMPLE_CHECKBOX_PATTERN)。
测试夹具 plan-scaffold.md 完整演示了这些边界:嵌套复选框(- [ ] Nested acceptance detail)、无编号前缀的行(- [ ] Missing numeric prefix must be ignored)、## Acceptance Criteria 下的复选框、以及 ## Final verification wave 中错误编号的行(- [ ] 3. Wrong final-wave label must be ignored)都会被正确忽略。
一个关键设计语义:即使顶层清单全部勾选,钩子仍然输出 block(只要 total > 0 且状态可续跑),直到"最终门禁(Final gate)运行且 Boulder 工作被标记为 completed"为止。也就是说"计划全勾"≠"结束",只有 Boulder 层面的 completed 状态才能真正终结续跑循环。
指令渲染:九个占位符如何注入当前计划状态
当钩子决定续跑时,renderDirective(src/codex-hook.ts)会把下列九个占位符逐一替换进 directive.md 模板(使用 String.prototype.replaceAll):
| 占位符 | 来源 | 说明 |
|---|---|---|
{{PLAN_NAME}} | Boulder 工作项 plan_name | 计划名称 |
{{PLAN_PATH}} | 解析后的计划文件路径 | 续跑时首先读取的对象 |
{{BOULDER_PATH}} | cwd/.omo/boulder.json | 状态文件的绝对路径 |
{{REMAINING_COUNT}} | checklist.remaining | 剩余顶层任务数 |
{{TOTAL_COUNT}} | checklist.total | 顶层任务总数 |
{{NEXT_TASK_LABEL}} | checklist.nextTaskLabel | 下一个未完成任务;无剩余时渲染为 "none (final gate pending)" |
{{WORKTREE_BLOCK}} | worktreePath | 无 worktree 时渲染为空串;有 worktree 时渲染一行 - Worktree: \
|
{{LEDGER_PATH}} | cwd/.omo/ulw-execute/ledger.jsonl | 证据账本路径(续跑时先读计划再读账本,二者是"唯一事实来源") |
{{SESSION_ID}} | 钩子 payload 中的会话 ID | 渲染为 codex:<session_id> |
渲染完成后,reason 即为一整段结构化的续跑指令。以 directive.md 为例,其内容约定包含五大部分:
- State:计划名、计划路径、Boulder 路径、剩余/总数、下一任务、worktree、账本路径与 session id;
- What to do this turn:先读计划与账本(不信任模型对前几轮的记忆)→ 剩余为 0 则执行 Final gate,否则取
## TODOs或## Final Verification Wave中第一个未勾选项 → 按ulw-execute技能分层(LIGHT 默认、HEAVY 需完整逐标准流程,不确定时取 HEAVY)→ 拆解原子子任务并通过multi_agent_v1.spawn_agent并行派发(除非存在具名阻塞依赖)→ 每个子任务消息必须自包含(TASK:命令式开头 +DELIVERABLE/SCOPE/VERIFY)→ 所有 Worker 的 DoneClaim 视为不可信输入,必须独立 AdversarialVerify → 全部验证通过后用apply_patch把- [ ]改为- [x]并向账本追加task-completed行; - Hard constraints:先有失败优先证明(RED→GREEN→SURFACE)才允许写生产代码;禁止
--dry-run作为证据;TUI 视觉证据必须走真实 xterm.js web 终端(node script/qa/web-terminal-visual-qa.mjs),禁止tmux capture-pane;禁止as any/@ts-ignore/@ts-expect-error;对每个触发事实成立的对抗性类别都要探测;清理收据(cleanup receipt)强制;PR/分支类工作必须使用任务自有 worktree; - Final gate:自行手动 QA + 对照验收标准自审,仅当用户明确要求严格/严谨/高精度评审时才派生一个门禁评审者(每个子任务至多一次);通过后才允许创建 PR / 合并 / 最终完成答复,并在最终答复前把 Boulder 工作标记为
completed;--make-pr模式在 PR 打开后交接(仅用户明确要求才合并),--ship模式则持续工作到 PR 被 MERGED 并清理 worktree; - Stop conditions for THIS turn:勾选一个顶层复选框后本轮即可结束(钩子会在下一轮重新评估);遇到外部阻塞写
<ulw-execute-blocked-external>;同一子任务连续 3 次失败修复后派生一次严格评审,仍受阻则以外部阻塞标记交接;触碰安全边界(破坏性命令、密钥外泄、生产写入)立即停止;全部勾选且 Final gate 通过则输出ORCHESTRATION COMPLETE并结束。
此外,directive.md 明确约定输出纪律:只暴露状态变更(子代理已派发、场景 PASS/FAIL 及工件路径、复选框已标记、证据已追加),禁止打印"Should I continue?"等无意义内容——续跑由钩子保证,计划与账本才是持久记录。
三道安全阀:防止无限续跑与错误续跑
续跑机制最危险的两个场景是"上下文耗尽后仍无限续跑"和"外部阻塞问题被反复重试"。组件为此内置了三重防护,均有对应测试用例锁定(见 test/codex-hook.test.ts):
1. stop_hook_active 防重入:当 payload 中 stop_hook_active 为 true(即 Codex 已经因钩子 block 处于续跑激活状态)时,钩子直接返回空输出,避免在一次续跑循环内反复触发(测试用例 #given stop hook is already active #when hook runs #then returns empty output)。
2. 上下文压力抑制(context-pressure suppression):钩子通过注入的 ReadonlyFileSystem 读取 input.transcript_path 指向的 transcript 文件,若其中出现任何上下文压力标记(context compacted、context_length_exceeded、context_too_large、codex ran out of room in the model's context window、skill descriptions were shortened、your input exceeds the context window、long threads and multiple compactions 等),立即返回空输出。这是"上下文窗口耗尽时防无限续跑循环"的安全阀,源码中由 transcriptHasContextPressureMarker(codex-hook.ts)实现——文件读取失败按无标记处理(返回 false),同样保证钩子永不因 I/O 异常而阻塞回合。
3. 外部阻塞逃生舱(external blocker escape hatch):当计划卡在"无论重试多少次都无法解决"的外部状态上(如凭据缺失、硬件缺失、授权被撤销、第三方服务不可用),directive.md 指示模型把 <ulw-execute-blocked-external> 作为回答的整个第一行输出(若 ultrawork 强制要求 ULTRAWORK MODE ENABLED! 开头,则该标记独占第二行)。钩子通过 hasAllowedExternalBlockerMarker(#L50-L61)识别这两种结构形态并放行本轮结束,把问题交还给用户处理,而不是对不变的外部状态无限重试。
值得强调的是,这个逃生舱的判定有额外防御:标记单独出现不足以放行,标记之后还必须有至少一行非空内容(即具体的阻塞原因与恢复条件)。这防止了"模型只是复述/回显了标记"(无论是来自 prompt 的引用还是不可信文本)就绕过续跑护栏的情况。directive.md 也同步要求:标记行之后必须写明确切阻塞原因与恢复工作所需满足的可观察条件。
对应的测试覆盖了三种典型形态:阻塞标记单独首行(<ulw-execute-blocked-external> + 原因 + 恢复条件)、ultrawork 开头 + 标记第二行、以及"仅在回答中途提及标记"(此时继续续跑)。
钩子注册与 CLI 接口
插件通过 hooks/hooks.json 把钩子注册到 Codex 的 Stop 事件:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/components/ulw-execute-continuation/dist/cli.js\" hook stop",
"timeout": 10,
"statusMessage": "(OmO 5.0.0-beta.74) Checking Ulw-Execute Continuation"
}
]
}
]
}
}
可见钩子以 CLI 子命令形式执行:node ${PLUGIN_ROOT}/components/ulw-execute-continuation/dist/cli.js hook stop,超时 10 秒。构建产物输出到 dist/,构建脚本定义在 package.json:bun build src/cli.ts --target node --format esm --outfile dist/cli.js。
CLI 入口 src/cli.ts 的行为如下:
- 只接受
hook stop与hook subagent-stop两个子命令,其余用法输出Usage: omo-ulw-execute-continuation hook <stop|subagent-stop>并以退出码 1 结束; - 从 stdin 读取 JSON payload(空输入直接返回),解析失败静默退出;
- 调用
runStopHook,仅在输出非空时写入 stdout; hook subagent-stop是刻意保留的兼容性 no-op:它未被注册到 hooks.json,且永远不会注入根级计划。SubagentStop事件即使被 CLI 接受,runStopHook也会因为isStopInput中hook_event_name === "Stop"的守卫而返回空——对应测试#given active Boulder work and a SubagentStop event #when hook runs #then returns empty output。
工程约束与代码规范:一个可维护的钩子组件
该组件自身的工程规范同样值得关注,它们是"钩子必须永不阻塞回合"原则在开发流程层面的落地(记录于 AGENTS.md):
- 技术栈:Node >= 20、npm、TypeScript 6 strict mode、Biome 2(lint/format)、Vitest 4(测试);
- 禁止项:
as any/as unknown、@ts-ignore/@ts-expect-error、enum、非空断言、默认导出(vitest.config.ts因框架要求豁免)——directive.md中"禁止as any/@ts-ignore"的硬约束与之一致; - 文件行数上限:每个
src/下 TypeScript 文件纯代码不超过 250 行,超限前按职责拆分——从实际源码看,boulder-reader.ts约 180 行、plan-checklist.ts约 170 行、codex-hook.ts约 100 行,均严格遵守; - 测试纪律:使用 Vitest 嵌套
describe,命名遵循#given ... #when ... #then ...形式(或行内// given、// when、// then注释),禁用 Arrange-Act-Assert 注释风格,夹具统一放在test/fixtures/。这一点在 codex-hook.test.ts 中得到充分体现,如#given context-window pressure系列用例锁定了上下文压力抑制行为; - 构建与打包:
package.json的files仅包含dist、directive.md、hooks、README.md、LICENSE、NOTICE,确保发布包轻量且自包含。
冒烟测试:一条命令验证完整续跑链路
README 提供了一个开箱即用的冒烟测试脚本,可在不依赖真实 Codex 会话的情况下验证钩子行为:
TMP=$(mktemp -d)
mkdir -p "$TMP/.omo/plans"
cat > "$TMP/.omo/plans/test.md" <<EOF
## TODOs
- [ ] Task one
- [ ] Task two
EOF
cat > "$TMP/.omo/boulder.json" <<EOF
{"schema_version":2,"active_work_id":"w1","works":{"w1":{"work_id":"w1","active_plan":".omo/plans/test.md","plan_name":"test","session_ids":["codex:smoke-session"],"status":"active"}}}
EOF
PAYLOAD='{"session_id":"smoke-session","turn_id":"t1","transcript_path":"","cwd":"'"$TMP"'","hook_event_name":"Stop","model":"gpt-5.5","permission_mode":"default","stop_hook_active":false}'
npm run build
echo "$PAYLOAD" | node dist/cli.js hook stop
PAYLOAD_LOOP='{"session_id":"smoke-session","turn_id":"t1","transcript_path":"","cwd":"'"$TMP"'","hook_event_name":"Stop","model":"gpt-5.5","permission_mode":"default","stop_hook_active":true}'
echo "$PAYLOAD_LOOP" | node dist/cli.js hook stop
rm -rf "$TMP"
预期结果:第一个命令输出包含 "decision":"block" 的 JSON(计划中还有 2 个未完成任务,续跑被触发);第二个命令(stop_hook_active: true)不输出任何内容(防重入生效);把 hook stop 换成 hook subagent-stop 同样无输出(兼容 no-op)。这个脚本同时验证了 Boulder 解析、清单统计、指令渲染与防重入四层行为,非常适合作为接入 CI 或本地排查的最小复现模板。
隐私与安全特性
最后值得一提的是该组件的隐私设计(README 明确声明):钩子只读取本地 hook payload、.omo/boulder.json、活跃计划文件与随包分发的 directive 模板;不发起任何网络调用(这也是 AGENTS.md 中"绝不让钩子发起网络调用"约束的来源),不存储任何遥测数据。组件以 MIT 协议开源(见 LICENSE),版本变更记录见 CHANGELOG.md。
总结:ulw-execute-continuation 用不到 700 行 TypeScript(含测试)实现了一个"只读状态 + 模板渲染"的续跑决策器,通过 Boulder 状态机、结构化清单解析、上下文压力抑制与外部阻塞逃生舱四条机制,在"自动续跑直到计划完成"与"绝不无限循环、绝不阻塞回合"之间取得了可靠平衡。如果你正在为长任务型 Agent 设计自动续跑机制,本组件的四层静默判定顺序与三把安全阀是可直接复用的参考范式。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



