trellis能力实现的核心 plugins/hooks 机制
Trellis 在 OpenCode 里的核心目标不是“让模型记住一堆流程”,而是在正确的时机把正确的上下文推到模型面前:新会话需要知道当前项目和工作流,长对话需要持续提醒当前阶段,子代理需要拿到任务材料,Shell 命令也需要知道自己属于哪个 Trellis 会话。
如果你还不熟悉 OpenCode 的 Hooks 与 Plugins 机制,可以先看这篇基础介绍:opencode Hooks 与 Plugins:把 Agent 工作流变成可编程系统。Trellis 下面这三个 plugin,正是这套机制在真实项目工作流里的一个完整应用。
这套能力主要由项目内的三个 OpenCode 插件完成:
.opencode/plugins/session-start.js:会话起步时注入完整的 SessionStart 上下文。.opencode/plugins/inject-workflow-state.js:每一轮用户消息都注入短小的工作流状态面包屑。.opencode/plugins/inject-subagent-context.js:在执行工具前,为 Bash 注入TRELLIS_CONTEXT_ID,并为 Task 子代理注入任务上下文。
它们共同构成了一个“push-based context system”:模型不必每次主动探索 .trellis/,插件会在普通聊天、每轮提醒、Shell 命令、子代理分发这四条路径上主动把上下文推过去。
OpenCode 如何发现这些插件
OpenCode 会自动发现项目下的 .opencode/plugins/*.js 文件,并调用模块导出的异步工厂函数。当前三个插件都采用同一种形态:
export default async ({ directory, client, platform, env }) => {
const ctx = new TrellisContext(directory)
return {
"chat.message": async (input, output) => {},
"tool.execute.before": async (input, output) => {},
event: ({ event }) => {},
}
}
这里的 directory 是项目根目录,TrellisContext 再基于它读取 .trellis/、解析当前活动任务、读 implement.jsonl / check.jsonl,以及生成与 OpenCode session 对齐的 context key。
需要注意的是,OpenCode 1.2.x 期望插件文件导出“函数”,而不是 { id, server } 这类对象形态。inject-subagent-context.js 里专门保留了注释:OpenCode 会遍历模块导出并逐个调用,如果导出的不是函数,就会触发 TypeError: fn is not a function。
第一次发送消息:session-start.js 注入 SessionStart 上下文
session-start.js 使用两个 hook:
chat.message:用户发送消息时触发,负责把完整 SessionStart 上下文拼到用户消息前面。event:监听通用事件流,目前主要处理session.compacted。
它并不是在 OpenCode 刚创建 session 对象时运行,而是在用户真正发送 chat.message 时运行。原因很直接:只有把上下文改写进 message part,它才会进入对话历史,后续模型才能持续看到。
注入了什么
真正生成上下文的是 .opencode/lib/session-utils.js 里的 buildSessionContext(ctx, input)。它会拼出一组结构化块:
<session-context>
Trellis compact SessionStart context. Use it to orient the session; load details on demand.
</session-context>
<first-reply-notice>
First visible reply: say once in Chinese that Trellis SessionStart context is loaded, then answer directly.
This notice is one-shot: do not repeat it after the first assistant reply in the same session.
</first-reply-notice>
<current-state>
Developer: ...
Git: branch ...; clean/dirty ... paths.
Current task: ...
Active tasks: ...
Journal: ...
Spec indexes: ... available.
</current-state>
<trellis-workflow>
Development Workflow 的压缩摘要
</trellis-workflow>
<guidelines>
Task context order for implementation/check: jsonl entries -> prd.md -> design.md -> implement.md
Available indexes ...
</guidelines>
<task-status>
Status: ...
Next-Action: ...
</task-status>
<ready>
Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed.
</ready>
这份上下文不是为了替代完整文档,而是给模型一个“启动地图”:当前开发者是谁、git 是否干净、当前任务是什么、Trellis 工作流有哪些阶段、有哪些 spec index 可以按需读取、当前下一步是什么。
原草稿里记录过一次真实注入效果:用户原本只是让模型优化 src/app/trackTest/page.tsx,但最终进入模型的消息前面先出现了 SessionStart 与 workflow-state 内容,然后才是原始用户请求。这个例子很有代表性,因为它说明 Trellis 并不是依赖模型主动想起流程,而是把流程写进了当轮输入。

什么时候跳过
session-start.js 有几类明确的 skip 逻辑:
- 如果当前 turn 是 Trellis 子代理:跳过。子代理上下文由
inject-subagent-context.js在父会话调用 Task 工具前注入,不能再塞主会话的 SessionStart。 - 如果环境变量关闭 hooks:
TRELLIS_HOOKS=0或TRELLIS_DISABLE_HOOKS=1。 - 如果是非交互模式:
OPENCODE_NON_INTERACTIVE=1。 - 如果当前 session 已经注入过:跳过。
如何去重
去重分两层:
- 进程内去重:
contextCollector.isProcessed(sessionID)判断当前运行进程是否已经处理过这个 session。 - 持久化去重:
hasPersistedInjectedContext(client, ctx.directory, sessionID)会读取 OpenCode 历史消息,检查 text part 的metadata.trellis.sessionStart === true。
注入成功后,插件会改写第一个文本 part:
parts[textPartIndex].text = `${context}\n\n---\n\n${originalText}`
markContextInjected(parts[textPartIndex])
contextCollector.markProcessed(sessionID)
如果找不到文本 part,则新建一个 text part 插到最前面。
为什么监听 session.compacted
OpenCode 长会话可能会压缩历史。session-start.js 在 event hook 中监听 session.compacted,一旦发现 event.type === "session.compacted" 且有 sessionID,就调用 contextCollector.clear(sessionID) 清掉进程内已处理标记。这样压缩后下一轮有机会重新注入 SessionStart,让被压缩掉的上下文重新出现。
每一轮用户消息:inject-workflow-state.js 注入工作流面包屑
如果说 SessionStart 是“会话启动地图”,inject-workflow-state.js 就是“每一轮防跑偏提醒”。它只使用一个 hook:
chat.message:每次用户发送消息都触发。
这个插件故意不做去重。长对话里最容易发生的就是模型忘记当前处于规划、实现还是收尾阶段,所以它每轮都会把一个短小的 <workflow-state> 块放到用户消息前面。
面包屑内容从哪里来
插件不会在代码里维护一份固定流程表,而是读取 .trellis/workflow.md 中的 [workflow-state:STATUS] 标签块:
[workflow-state:planning]
Load `trellis-brainstorm`; stay in planning.
...
[/workflow-state:planning]
解析正则是:
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g
也就是说,workflow.md 是单一事实源。状态文本改动只需要改 workflow.md,插件只是解析它。如果缺少对应状态标签、或者 workflow.md 不可读,插件不会静默使用陈旧内置表,而是降级成可见的通用提示:
Refer to workflow.md for current step.
这个降级是有意设计的:宁可让用户发现 workflow 文档坏了,也不要让插件用过时默认值掩盖问题。
如何确定当前状态
inject-workflow-state.js 会通过 TrellisContext.getActiveTask(input) 找当前 session 的活动任务,再读取任务目录下的 task.json.status。有任务时输出:
<workflow-state>
Task: 07-21-xxx (in_progress)
Tools: `trellis-implement` / `trellis-research` are sub-agent types only ...
Flow: `trellis-implement` -> `trellis-check` -> `trellis-update-spec` -> commit ...
</workflow-state>
没有活动任务时,则使用伪状态 no_task:
<workflow-state>
Status: no_task
No active task. First classify the current turn and ask for task-creation consent before creating any Trellis task.
...
</workflow-state>
什么时候跳过
它的 skip 条件与 SessionStart 类似,但目标不同:
- Trellis 子代理 turn:跳过,因为子代理上下文来自父会话的
tool.execute.before注入。 - hooks 被环境变量关闭:
TRELLIS_HOOKS=0或TRELLIS_DISABLE_HOOKS=1。 - 非交互模式:
OPENCODE_NON_INTERACTIVE=1。 - 当前目录不是 Trellis 项目:没有
.trellis/就跳过。
这里有一个细节:源码注释早期写过“没有 active task 时静默跳过”,但当前实际逻辑已经会在无任务时注入 no_task 伪状态。这也是读源码时要以实现为准的地方。
执行工具之前:inject-subagent-context.js 连接 Shell 和子代理
第三个插件不处理普通消息,而是拦截工具调用:
tool.execute.before:OpenCode 执行工具前触发。
它做两类事:
- 如果工具是 Bash,把当前 OpenCode session 对应的
TRELLIS_CONTEXT_ID注入到命令前面。 - 如果工具是 Task,并且目标是 Trellis 子代理,就把任务材料和角色说明注入到子代理 prompt。
Bash:给命令补上 TRELLIS_CONTEXT_ID
OpenCode TUI 不一定会把 OPENCODE_RUN_ID 暴露给 Bash。问题是 Trellis 的命令行脚本,例如:
python3 ./.trellis/scripts/task.py current --source
需要知道“当前命令属于哪个 OpenCode 会话”,否则就可能找不到活动任务,或者在多窗口时误判。
所以当 input.tool 是 bash 时,插件会检查 output.args.command 或 output.args.cmd。如果命令开头还没有设置 TRELLIS_CONTEXT_ID,就用 ctx.getContextKey(input) 生成 context key,然后在命令前加前缀:
export TRELLIS_CONTEXT_ID='opencode_session_xxx'; 原始命令
Windows PowerShell 场景则使用:
$env:TRELLIS_CONTEXT_ID = 'opencode_session_xxx'; 原始命令
它还会识别几种“已经设置过”的开头,避免重复注入:
TRELLIS_CONTEXT_ID=...export TRELLIS_CONTEXT_ID=...env ... TRELLIS_CONTEXT_ID=...$env:TRELLIS_CONTEXT_ID = ...
这一步看似很小,但它让 shell 里的 Trellis 脚本与 OpenCode 对话共享同一个 active task 指针。
Task:只处理支持的 Trellis 子代理
当 input.tool 是 task 时,插件先读取 output.args.subagent_type,并兼容去掉 trellis- 前缀:
const subagentType = (rawSubagentType || "").replace(/^trellis-/, "")
当前支持三类:
implementcheckresearch
其中 implement 和 check 必须有有效任务目录;research 可以只使用项目/spec 结构信息,不强制要求任务目录。
子代理如何解析目标任务
inject-subagent-context.js 的任务解析优先级非常关键:
- 优先用当前
input.sessionID推导出的 runtime context key,读取.trellis/.runtime/sessions/<contextKey>.json中的current_task。 - 如果 session runtime 没命中,就看 dispatch prompt 里是否显式写了第一行提示:
Active task: <path>。 - 如果仍然没有命中,才使用 single-session fallback:只有当本地 runtime session 文件刚好只有一个时,才用它指向的任务;多窗口时拒绝猜测。
第二点是为了多窗口和跨平台分发的稳定性。Trellis 的 workflow 里也明确要求:分发子代理时 prompt 以 Active task: <task path from task.py current> 开头。这样即使 runtime identity 传递不完整,子代理仍然能明确知道自己服务哪个任务。
注入给子代理的上下文顺序
implement 子代理使用 getImplementContext(ctx, taskDir),上下文顺序是:
- 读取
implement.jsonl,并把其中引用的文件内容展开。 - 注入
prd.md。 - 如果存在,注入
design.md。 - 如果存在,注入
implement.md。
check 子代理类似,但第一步读取的是 check.jsonl。如果原始 prompt 中包含 [finish] 标记,则进入 finish-mode,复用 check context,但换成 final check 的角色说明。
research 子代理不强制绑定任务,它会扫描 .trellis/spec,构造项目 spec 目录结构,并给出搜索提示,例如:
## Project Spec Directory Structure
.trellis/spec/
├── backend/
├── frontend/
├── guides/
然后附上“Spec files 用 .trellis/spec/**/*.md,代码搜索用 Glob/Grep”等提示。
如何改写子代理 prompt
插件最后调用 buildPrompt(agentType, originalPrompt, context, isFinish)。生成的 prompt 会带上明显标记:
<!-- trellis-hook-injected -->
# Implement Agent Task
You are the Implement Agent in the Multi-Agent Pipeline.
## Your Context
...jsonl 引用文件 + prd/design/implement...
---
## Your Task
...原始 prompt...
然后它不是替换整个 args 对象,而是原地修改:
args.prompt = newPrompt
源码注释说明了原因:Task tool runtime 持有的是同一个 args 对象的本地引用,整对象替换不会生效,必须原地改字段。
三个插件如何贯穿一个 Trellis 任务生命周期
把三个插件放在一起看,Trellis 在 OpenCode 里的上下文链路是这样的:
- 会话启动:用户第一次发消息,
session-start.js通过chat.message注入完整 SessionStart,告诉模型当前项目、当前任务、workflow 摘要、spec 索引和下一步。 - 每轮对齐:后续每一轮用户消息,
inject-workflow-state.js继续通过chat.message注入短面包屑。它不去重,因为目标是防止长会话漂移。 - 命令继承会话身份:当模型运行 Bash 时,
inject-subagent-context.js在tool.execute.before给命令补TRELLIS_CONTEXT_ID,让task.py current --source这类命令能定位同一个 active task。 - 子代理拿到完整任务材料:当主会话调用 Task 创建
trellis-implement、trellis-check或trellis-research时,同一个tool.execute.before会在子代理 prompt 里注入角色说明和对应上下文。 - 子代理内部不重复注入主会话上下文:
session-start.js和inject-workflow-state.js都会识别trellis-implement/trellis-check/trellis-research,在子代理 turn 里跳过,避免上下文层层叠加。
因此,三个插件不是三个孤立功能,而是分工明确的上下文管道:
session-start.js解决“新会话一开始知道什么”。inject-workflow-state.js解决“长对话每一轮不要忘记当前阶段”。inject-subagent-context.js解决“工具和子代理继承正确任务身份与材料”。
设计取舍:为什么用推送式上下文
这套设计的核心取舍是:把关键流程信息放到 hook 层主动推送,而不是要求模型在每一步主动检索。
好处有三点:
- 一致性更强:SessionStart、workflow-state、sub-agent context 都来自项目文件和 task runtime,而不是模型记忆。
- 跨边界传递:普通聊天、Bash、Task 子代理是不同执行边界,插件把同一个 Trellis context key 穿过去。
- 可退化可排查:hooks 可以用环境变量关闭;调试信息写入
/tmp/trellis-plugin-debug.log;workflow tag 缺失时给出可见降级提示。
也有需要注意的地方:
session-start.js会把较长上下文写入第一条用户消息,所以必须有去重和 compact 后重注入机制。inject-workflow-state.js每轮都注入,内容必须短而准,真正的长说明仍然应该留在.trellis/workflow.md。inject-subagent-context.js依赖 runtime session、Active task:提示和 single-session fallback 的组合;多窗口场景下最好显式写Active task: <path>。
小结
Trellis 的 OpenCode 集成不是简单地“加几个提示词”,而是把工作流状态嵌入 OpenCode 的 hook 生命周期:
chat.message的第一次注入负责建立会话方向。chat.message的每轮面包屑负责保持流程不漂移。tool.execute.before的 Bash 注入负责让命令继承会话身份。tool.execute.before的 Task 注入负责让子代理拿到任务材料并按角色执行。
这也是 Trellis 能在多轮对话、子代理和命令行之间保持一致任务上下文的关键:上下文不是靠模型“记住”,而是由插件在正确时机写入正确通道。