关于 terse-improve
用于改进指定 Terse 工作流的技能,通过检查实现、生成的集成定义和生产运行记录来分析并验证变更。面向维护 Terse job 的开发者,强调确定性工具调用、安全测试和过程说明。
主要功能
- 01检查 job 代码与生成的集成定义
- 02查看生产运行记录
- 03审查工具用法与提示词
- 04实施并验证工作流变更
- 05说明命令行运行及测试结果
适用场景
- 01诊断失败或取消的工作流运行
- 02过滤无关触发事件
- 03将 Agent 操作改为确定性调用
- 04通过重放验证工作流修复
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ terseai/ terse --skill terse-improve安装后在 Claude Code 中输入/terse-improve确认已加载。
详细介绍
改进 Terse 工作流
改进名为 $ARGUMENTS 的 Terse 工作流
工作流在代码中定义为一个作业(src/terse.jobs.ts 中的 createJob);以下 CLI 命令使用该作业名称。
了解如何改进工作流
你应使用以下来源了解 Terse 平台:
- 项目本身的代码(如果存在):(
src/terse.jobs.ts、`src/jobs/) - 生成的文件应作为主要事实来源,并说明 Terse 的工作方式:
src/terse.generated.ts是入口文件,其文件头列出所有可用集成,并附有src/terse.generated/文件夹的行号索引。该文件夹中每种集成和类型各有一个文件(用于触发器工厂和负载类型的<integration>.triggers.ts、用于类型化工具方法、资源和技能的<integration>.tools.ts、如attio.schemas.ts这样的工作区架构文件,以及common.ts中的共享基础设施)。请使用索引,只打开作业涉及的文件。生成的文件说明了现有内容及其确切结构;如果它们与在线文档不一致,以生成的文件为准——如果缺少辅助函数,就重新运行terse generate,切勿依据生成的文件不支持的文档说法编写代码。 - Terse CLI:(
terse --help) - 最后才考虑使用 Terse 在线文档(docs.useterse.ai)。先获取索引页(https://docs.useterse.ai/llms.txt),了解有哪些页面,然后只获取改进工作流所需的特定页面。
不要搜索或读取 terse-sdk 包的 dist 文件。
条件允许时优先使用计算机操作工具
如果环境提供计算机操作工具,且可通过图形界面可靠地完成任务,请使用这些工具。这包括 Claude Code、ChatGPT 等应用以及其他已安装或已登录应用中的浏览器和桌面界面。
- 每次调用计算机操作工具前,都要征求明确许可,包括只读检查。根据紧接着要执行的操作提出请求;说明具体的应用或网站、确切的界面操作以及预期结果。询问并等待答复:“我注意到当前可以使用计算机操作工具。你愿意让我在 [应用/网站] 中执行 [操作],以便 [预期结果] 吗?”如果与当前任务不符,切勿重复使用通用仪表板示例。
- 对于 API 密钥设置,说明你要打开哪个提供商的 API 密钥页面,以及打算进行导航、创建具有限定权限的密钥,还是验证配置;说明在显示或复制密钥时,控制权由用户保留。对于通过
terse integrate connect打开的授权窗口,说明要连接哪个集成,并请求用户查看并继续该特定授权流程;让用户自行完成登录或多重身份验证。 - 如果用户明确要求在当前任务中使用计算机操作工具,即视为已获准在该请求范围内使用;不要重复询问。除此之外,仅仅因为工具可用并不代表获得许可。
- 获得许可后,可在请求范围内进行常规导航、检查、截屏、验证、点击、输入和上传,无需每一步都询问。若要转到其他应用、账户、界面或实质上不同的目标,则需要再次询问。
- 如果用户拒绝或未答复,请勿使用计算机操作工具。条件允许时改用原生工具,否则说明用户需要手动执行的最小步骤。
- 在执行用户尚未明确授权的任何重要外部操作前,立即询问:发送或发布内容、提交重要表单、删除、购买、更改权限或安全设置,或修改生产数据。用户明确要求执行特定操作,即表示已批准该操作范围内的常规界面步骤;无需每次点击都再次询问。
- 仅在用户明确要求连接该集成时,才开始 OAuth 连接。获得授权后可导航授权流程,但遇到密码、多重身份验证代码、付款信息或其他敏感字段时,应将控制权交还用户;切勿通过计算机操作工具读取、泄露或输入这些信息。
- 操作时应限定在请求的界面内,避免打开无关的私人内容。操作后检查结果界面,并如实报告实际发生的情况。
- 使用原生工具处理源代码编辑、精确结构化读取、批量或可重复操作,以及 Terse CLI 命令。如果界面无法可靠或安全地完成任务,则使用专用连接器或 API。
- 如果计算机操作工具不可用、应用未安装或未登录、界面无法操作,或者该界面会暴露机密或增加风险,请妥善切换方案。说明用户需要手动执行的最小步骤,然后自动继续。
- 遵守下方所有确认与测试安全规则。计算机操作只改变交互界面,不改变权限边界。
步骤
src/terse.generated.ts 和 src/terse.generated/ 文件夹是已连接集成、可用触发器、技能、资源和确定性封装器的事实来源。根文件顶部的注释列出了 Terse 当前可用的所有集成——绝不要围绕列表中没有的集成做规划——并为每个集成的文件编制索引,列出每个工具、触发器和资源的准确文件及行号。请结合任务实现阅读根文件。不要运行 terse integrate list——生成的文件已反映 terse integrate 在此工作区连接的集成,或可供连接的集成。要了解可用集成能做什么,terse integrate tool <type> --json 会列出其工具及说明,而 terse integrate tool <type> <tool-name> --json 会返回某个工具的输入/输出架构。要查看工具实际返回的内容,terse integrate tool run <tool> --params '<json>' 可单次运行该工具——请参阅测试约定中的“从 CLI 测试工具调用”。
如果生成文件缺失,或其中任务所用集成的内容已经过时,或者头部索引与叶文件中的实际内容不一致,请重新运行 terse generate,不要猜测缺少的辅助函数。绝不要直接编辑生成文件。
叙述运行过程。 用轻松的第一人称向用户说明正在进行的工作:“我正在拉取生产历史,看看实际出了什么问题”“加上新筛选条件后,重放现在通过了”。每次阶段转换时,都用通俗随意的说法点明阶段名称及其顺序位置——诊断、分析、实现、验证——例如:“诊断完成了——这是第 1 阶段,共 4 个阶段。接下来我会仔细检查任务,看看还能如何改进。”绝不要提及内部步骤编号;用户应始终能从文字中了解进展。
标记每次 CLI 运行。 terse CLI 执行操作是用户必须能看到的事件;工具调用常会被运行环境折叠,因此要通过叙述让用户知情。每条 terse 命令执行前,立即输出一行标记:
⏵
terse replay 01hq3v…— 针对修复重新运行失败的生产事件
每条命令对应一条标记:先用反引号括起原样命令,再用几个词说明原因。连续执行的轻量读取操作(terse test list、terse test show)可以共用一条标记。状态变更或长时间运行的命令(generate、deploy、replay、test run)完成后,要在正常叙述中报告结果。绝不要把 terse 调用藏在管道、子 shell 或脚本中,让用户看不到它已运行。
每次测试运行前都要说明。 对于 terse replay 或 terse test run,仅有标记行还不够:运行前要立即用一到三句轻松的话说明测试覆盖什么、为什么现在运行,以及什么结果才算理想——“这次重放会重新运行周二失败的那个确切生产事件;加上新筛选条件后,我预计它会跳过机器人评论,而不是崩溃。”运行结束后,要用同样的语气,根据先前说明的预期给出结论。轻量读取操作(terse test list、terse test show)只需标记。
如果 terse test run 输出了 TERSE TEST REPORT,要立即在自己的消息中呈现。第一句话必须是“The test ran as expected”或“The test did not run as expected.”然后准确列出报告中的副作用,并说明与先前预期不一致之处。
1. 查找工作流
打开 src/terse.jobs.ts 和 src/terse.generated.ts(根目录下的生成文件)。如果入口文件是副作用导入清单,请沿着这些导入进入 src/jobs/。找到与所请求工作流名称匹配的任务,并阅读完整实现——触发器、技能、筛选器和处理程序。然后沿着根文件中的索引,只查看该任务导入或使用的集成对应的 terse.generated/ 叶子文件,并完整阅读这些文件——以下分析需要在上下文中包含它们的确切签名。
CLI 仍可将 src/index.ts 作为旧版回退入口加载,自定义布局也可以通过 --entry-file 覆盖入口文件。
2. 获取生产运行历史
在猜测如何改进之前,先查看该任务在生产环境中的实际表现。terse history CLI 命令会从已部署的 Agent 获取过往运行记录,让你了解出现了哪些问题。
建议的调用方式(完整标志列表请参阅 https://docs.useterse.ai/reference/cli):
# Recent runs as JSON, with the trigger event payload for each one (cheap, ideal for this skill)
terse history "<job-name>" --json --triggers --limit 20
# Narrow to failures only
terse history "<job-name>" --json --triggers --status failed,cancelled --limit 20
# Full chat history (model events + trigger event) for a single run — heavier, use when you need to see exactly what the agent decided
terse history --run-id <run-id> --json
如果 JSON 模式命令返回结构化的 { "error": ... } 信封,且其中 actionRequired: true,或者退出代码为 2,请停止并提示用户执行所需的后续步骤或访问相应 URL,不要将其视为代码错误。
需要关注的内容:
- 失败或取消的运行——触发事件会显示导致任务出错的输入。
- 重复模式——同类事件反复出现问题,可能意味着缺少筛选器、提示词含糊不清或缺少技能。
- 无效运行——应被筛除的机器人事件、草稿或无操作事件。
- Agent 越权——
generateText执行了确定性工作(此时应使用toolbox)。检查聊天历史,查看工具选择是否错误,或参数是否为臆造。
如果用户尚未部署该任务(找不到 Agent),请跳过此步骤,并依据源代码和 terse test list 中的示例事件进行判断。
3. 分析改进点
完成前,请逐项检查以下所有方面:每项都要进行修改,或确认其已无问题。不要遇到第一个容易改进的地方就停下。从工具使用开始,因为将工作从 Agent 转移到 toolbox 通常是影响最大的改进。
本文件底部的“Terse Job Code Conventions”部分是“约定”和“持久性”两项审核所依据的标准。最常见的修复前后示例,请参阅下文的“Common Improvement Patterns”部分。
工具使用
- 已知调用与判断:对于参数已知的操作,使用
toolbox,而不是generateText。请阅读terse.generated/<integration>.tools.ts中提供的方法——根文件的索引会指向确切行号。 - 不必要的 Agent:如果处理程序只运行确定性工具,请完全移除
generateText,直接调用toolbox。 - 提示词执行集成操作:提示词中出现“发布到 Slack”“创建 Linear 问题”或“添加标签 X”等表述,通常意味着这一步应该由代码完成。提示词只应用于需要判断的任务(总结、分类、起草)。
- 技能范围:
skills控制模型在generateText运行期间可以选择哪些工具。toolbox.<integration>不受skills影响——如果你想进行确定性调用而不配置 Agent,请使用它。 - 多步骤流程:先执行已知操作(
toolbox.slack.sendMessage),然后针对需要判断的部分进行范围明确的generateText调用,最后通过toolbox发布结果。 - 工具结果:如果后续步骤需要确定性调用的返回值,请将其保存下来(例如,用于串接消息的
message.message_ts)。
提示词质量
- 具体性:提示词是否明确告诉 Agent 要做什么?“处理此事件”这类模糊提示词会浪费令牌,并导致结果不一致。请具体说明:“用 3 个要点总结 PR 变更,并发布到 Slack。”
- 事件上下文:是否通过
event.formatForAgentRunner()包含完整的事件负载? - 边缘情况:提示词是否说明遇到模糊情况时该怎么做?例如:“如果 PR 没有描述,则仅根据差异进行总结。”
- 格式说明:是否指定了输出格式?例如“格式化为 Block Kit JSON”,而不是任由 Agent 决定。
- 长度:提示词是否过长?将多步指令拆分为单独的 Agent 运行,或对可预测的部分使用确定性工具调用。
事件筛选
- 机器人事件:是否应跳过机器人生成的事件?(
event.sender.login.includes("[bot]")) - 草稿/WIP:是否应忽略草稿 PR 或 WIP 项目?
- 特定来源:是否应筛选来自特定用户、仓库或频道的事件?
- 成本:每个未筛选的事件都会触发一次 Agent 运行。筛选能节省真金白银。
错误处理
- 缺失数据:代码是否处理了事件数据可能缺失的情况?(例如,没有正文的 PR、没有提交的推送)应使用自定义错误快速失败,而不是勉强继续运行。
- 提示词韧性:Agent 提示词是否说明了工具调用失败时该怎么做?
约定
- 根据约定部分中的每条规则审查任务;该部分就是检查清单。
- 报告发现的所有违规项。只修复本次改进中已涉及的代码路径上的违规项;将独立的风格改造作为明确的可选项提出。对于每个文件对应一个任务的布局违规,这意味着只拆分本次已在修改的任务,其余部分保持内联并标记出来。
持久性
- 持久化任务:根据约定部分中的“持久化任务风格”规则审查处理程序——步骤位置、可序列化边界、分支辅助函数、已记录的分支条件。
- 非持久化任务:检查是否引入了约定部分中“何时应持久化”所述的持久化信号。强制信号(
waitForInput、sleep)意味着任务已出错,而不是有待改进——必须将durable: true,并按要求重构步骤;要将其说明为必需的修复,而不是作为问题提出。判断信号(有三个或更多产生副作用的阶段)意味着切换为持久化属于可选改进,应作为建议提出。
技能配置
- 缺少技能:模型在
generateText运行期间需要的所有集成是否都已列出?如果提示词要求模型发布到 Slack,但skills中没有 Slack,该 Agent 步骤就会失败。 - 不必要的技能:是否存在 Agent 实际不会使用的技能?移除它们以减少混淆。
- 范围:仓库/频道/团队的范围设置是否正确?范围过广会让 Agent 获得不应拥有的访问权限;范围过窄则会妨碍它完成工作。
4. 与用户确认行为变更
如果任何拟议的改进会改变任务的可观察行为——例如跳过原本会处理的事件的筛选器、重写提示词、更改输出位置,或因判断信号而切换持久性——请在实施前将这些决定交由用户确认。为每项提供推荐答案,并将相关问题合并提问;每次最多提出四个问题。机械性修复(类型、错误类、文件结构)和强制性的持久化切换无需确认。
如果某项事实可以从代码、运行历史或文档中找到,就应自行查找,而不是提问。如果在无人可答的无头环境中运行,则采用推荐答案,并在最终摘要中说明理由。
5. 实施改进
编辑任务文件——单任务项目中的 src/terse.jobs.ts、清单布局下的 src/jobs/<name>.ts,或仓库配置的 --entry-file。按照约定部分中的规范进行修改。
如果改进需要新的集成,请在连接前进行说明:terse integrate describe <type> --json 会返回 installationType(form 或 OAuth),对于表单安装,还会返回 connect 所需的确切字段——切勿猜测字段名称。表单安装通过 --field key=value 传入值,并通过 --fields-stdin 传入密钥;OAuth 安装会打开浏览器并以状态码 2 退出,同时提供后续操作说明,因此请先告知用户,再执行 terse integrate wait <type>。
连接新的集成后,或需要更新辅助工具时,请重新运行 terse generate,并重新阅读头部索引及受影响的叶子文件——切勿手动编辑生成的文件。
6. 在本地验证更改
在证明更改仍然有效之前,不要交付。
重放和测试运行会使用真实凭据,针对真实生产事件执行实际处理程序。在本地首次运行会写入外部目标的任务之前,请遵循本文底部的“测试安全约定”部分:将写入目标设为测试目标或使用测试模式密钥,并在写入任何生产目标之前征求用户同意。
以下是两个互补的循环:
重放导致故障的确切生产运行。 如果你在步骤 2 中使用 terse history 找到了出错的运行,请使用新代码在本地重放:
terse replay <run-id>
terse replay 会从 Terse 后端获取原始序列化触发事件,并在本地使用详细的 Agent 输出运行任务的 onTrigger。这是确认你在生产环境中发现的问题确实已修复的最快方式。
或者,以非交互方式针对新的示例事件运行。 如果没有特定运行需要重现,或者想确认正常流程没有退化:
terse test list "<job-name>" --json
terse test show <id> "<job-name>" --json
terse test run "<job-name>" --id <id>
terse test list 会从后端获取真实示例事件(对于 cron 和 webhook 触发器,则会生成合成事件),并为事件分配内容寻址 ID。
terse test show 可让你在运行前检查特定的缓存示例。
terse test run 无需 TTY 即可执行处理程序。
如果存在多个任务,请明确传入任务名称,因为非交互式任务加载无法提示选择。
仅在手动交互会话中使用不带参数的 terse test。
要重放特定负载,或在没有可用示例事件时,请手动编写事件文件,并使用 terse test run --event-file <path> 运行:如果结构不匹配,命令会输出预期的封装结构和确切的验证问题——请根据错误输出修正文件,而不要猜测字段。
有关这些命令的完整选项列表,请参阅 https://docs.useterse.ai/reference/cli。
7. 对项目进行类型检查
本地执行看起来正常后,请运行类型检查器,确保更改在静态检查中有效,再进行部署:
pnpm exec tsc --noEmit
如果符合项目的配置方式,请使用 npx tsc --noEmit 或 pnpm run build。报告结果之前,请修复所有错误。
8. 说明更改
实施并验证后,请总结你更改了什么以及原因。如有帮助,请引用 terse history 中促成每项更改的生产运行,并注明通过哪些 terse replay / terse test list/show/run 调用确认了修复。
9. 部署前先询问
不要自动运行 terse deploy。解释所做的更改后,询问用户现在是否要部署。
如果你更改了已部署的持久化作业的步骤结构(添加、删除或重新排序步骤),请在同一次询问中提醒用户:进行中的运行会基于已更改的日志恢复,可能失败或行为异常,因此应选择业务空闲时部署。
示例提示:
已在本地验证这些改进。要运行
terse deploy部署到生产环境吗?(此操作会同步项目中的所有作业 — 已删除的作业会从远程删除。)
- 如果用户同意,运行
terse deploy并报告结果。 - 如果用户拒绝或希望继续修改,则停止,不要部署,并提醒他们准备好后可以运行
terse deploy。
常见改进模式
分析阶段最常采用的修复方式的前后示例。方法和常量名称来自项目的生成文件;切勿自行编造。
添加机器人过滤
// BEFORE: runs on every event
onTrigger: async (event) => { ... }
// AFTER: skip bot events
filter: async (event: GithubPRTrigger) => {
return !event.sender.login.includes("[bot]") && !event.pullRequest.merged
},
onTrigger: async (event: GithubPRTrigger) => { ... }
提高提示词的具体性
// BEFORE: vague
await generateText({ prompt: `Review this PR: ${event.formatForAgentRunner()}`, skills: [...] })
// AFTER: specific instructions, format, edge cases
await generateText({
prompt:
`Review PR "${event.pullRequest.title}" (${event.pullRequest.url}). ` +
`Look at the diff and leave a concise review comment. ` +
`Focus on: correctness, edge cases, and naming. ` +
`Skip style nits. If the PR looks good, approve it with a short note. ` +
`Context: ${event.formatForAgentRunner()}`,
skills: [...],
})
拆分确定性操作与 AI 操作
// BEFORE: agent decides everything including the message send
await generateText({ prompt: `Send a welcome message and summarize: ${event.formatForAgentRunner()}`, skills: [...] })
// AFTER: deterministic send via toolbox (no skill needed), then AI analysis in thread
import { generateText } from "terse-sdk"
import { toolbox } from "./terse.generated"
const message = await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: `New PR from ${event.sender.login}: ${event.pullRequest.title}`,
thread_ts: "",
blocks: "",
})
const summary = await generateText({
prompt: `Summarize the changes in this PR. Context: ${event.formatForAgentRunner()}`,
skills: [Skills.github({ repos: [Repos.MyOrg.MyRepo] })],
})
await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: summary,
thread_ts: message.message_ts,
blocks: "",
})
添加类型安全
// BEFORE: untyped event
onTrigger: async (event) => {
await generateText({ prompt: `Handle: ${event.formatForAgentRunner()}`, skills: [...] })
}
// AFTER: annotate with the precise trigger type that matches your trigger factory.
// `Triggers.github.onPROpened(...)` returns a typed trigger, so `event` infers
// as `GithubPROpenedTrigger` — annotating just makes it explicit.
import { generateText } from "terse-sdk"
import type { GithubPROpenedTrigger } from "./terse.generated"
onTrigger: async (event: GithubPROpenedTrigger) => {
const { title, url } = event.pullRequest
await generateText({
prompt: `Review PR "${title}" at ${url}. Context: ${event.formatForAgentRunner()}`,
skills: [...],
})
}
测试安全约定
这些约定适用于每一次本地作业执行 — terse test run、terse replay 和临时工具调用(terse integrate tool run)。本地运行会使用真实凭据执行真实处理程序:除非你将目标指向安全位置,否则测试运行并非沙盒环境。
使用真实事件作为输入,使用测试目标作为输出
示例事件(terse test list、terse replay)是真实的生产数据;将它们用作输入正是目的所在 — 真实的负载有助于发现真实的 Bug。关键在于副作用:测试运行绝不能向真实人员或真实渠道写入数据。测试运行期间,邮件、消息、工单更新、CRM 写入等都必须发送到用户指定的测试目标,绝不能发给事件中的客户、渠道或记录。
唯一允许的例外是:切换到真实目标后,进行一次验证运行,并在运行前告知用户。
API 测试密钥
如果工作涉及会产生费用或对客户可见的外部 API(Stripe、Resend,……),请询问用户一次 — 将此问题合并到询问测试目标时 — 平台是否提供测试密钥或沙盒密钥:“构建期间是否有可供我使用的测试模式密钥?如果有,请使用 terse secrets add <NAME> 添加。”每次本地运行都使用测试密钥;切换到线上密钥是最终切换到真实目标的一部分。
密钥值只能写入,不能读取:绝不要尝试读取已存储的密钥,以确认它是测试密钥还是线上密钥。应通过询问来确认,而不是检查。
没有测试密钥:读取无需许可,写入须先询问
没有测试密钥时,本地运行使用与生产环境相同的凭据:
- 读取无需许可。 在测试运行期间列出、获取和查询生产数据始终可以进行。
- 每个写入目标都须先询问。 在首次本地运行向某个生产环境目标(真实代码仓库、线上受众或客户记录)写入数据之前,必须明确询问用户是否同意。此询问适用于之后针对同一目标的运行;新目标则需重新询问。
从 CLI 测试工具调用
要了解外部 API 返回什么——记录的结构、状态字段有哪些选项、存在什么频道——请从 CLI 运行工具:
terse integrate tool run slack.listChannels
terse integrate tool run attio.records --params '{"request":{"action":"query","objectSlug":"deals","filter":null,"limit":5,"offset":null}}'
- 使用线上的名称(
attio_records)或点分形式(attio.records)指定工具;名称错误时会报错并列出有效名称。 --params接受工具在线上的 JSON 形状,即terse integrate tool <type> <tool-name> --json输出的确切输入架构。请阅读该架构,而不是生成的toolbox.*签名,因为它们可能与线上形状不同。参数较大时,请改用标准输入传递,而不要使用--params。- 连接会自动从项目固定的连接(
terse integrate use <type> <connection-id>)或工作区的唯一连接中解析;如果有多个连接且未固定任何一个,请使用terse integrate connections <type> --json列出 ID,然后重试并指定--integration <id>,或固定其中一个。 - 结果以原始 JSON 格式输出到 stdout;失败时会以非零状态码退出,并将错误输出到 stderr。
发现阶段的运行应保持只读:查询、列出、获取。写入操作应放在作业中,并遵循上述章节的规定。
对于没有工具箱工具且调用需要注入密钥的 API,请在现有作业中运行读取操作:
- 在作业处理器顶部插入一个连续代码块,用于执行读取、记录结果,并以
return结束——其下方的代码都不会执行。 - 使用
terse test run运行,这样密钥注入方式就与真实作业完全一致。 - 记录发现的内容,然后删除该代码块,并使用
git diff确认处理器已逐字节恢复到之前的状态。
切勿为此创建单独的临时作业,也切勿在该代码块仍存在时部署——terse deploy 会同步项目中的所有作业。
发现的内容应以命名且显式指定类型的常量形式写入作业代码——参见代码约定中的“发现的值是带类型的常量”。
Terse 作业代码约定
这些约定适用于你在项目作业文件(src/terse.jobs.ts 和 src/jobs/)中编写或修改的每一行作业代码。优先级:涉及事实(API 签名、CLI 标志、可用性)时,以 Terse 在线文档为准;涉及风格时,以本文档为准。
本次会话中用户给出的覆盖规则优先于本文档。
类型规范
所有内容都要指定类型;极少进行类型断言。 几乎不要使用 as 或 any。类型断言通常意味着当前方法有误:应在更高层重构、修正第三方依赖的封装方式,或询问用户该如何处理。优先使用类型守卫、泛型和 satisfies。
在信任边界使用 Zod。 数据跨越你无法控制的边界时,必须且仅在该处使用 zod 架构(静态类型使用 z.infer):
- 未由官方 SDK 提供类型的外部 API 响应
- webhook 触发器载荷
jobStep的inputSchema/outputSchemagenerateText的outputSchema
你自行构造的内部形状应使用普通类型。官方 SDK 的类型可直接信任;不要在运行时重新验证。
从架构推导静态类型,以确保只有一个真实来源:
const Classification = z.object({
severity: z.enum(["critical", "routine"]),
reason: z.string(),
})
type Classification = z.infer<typeof Classification>
显式返回类型。 每个函数声明和命名箭头函数都必须显式指定返回类型,包括辅助函数。内联回调——map/filter lambda,以及内联传给 createJob 的 filter 和 onTrigger 函数——仍由系统推断类型。
发现的值是带类型的常量。 通过探测外部状态发现的值(受众 ID、已验证的域名、频道 ID)应作为带名称且显式指定类型的常量写入,绝不能以内联裸字符串字面量的形式放在调用中。如果 SDK 提供对应类型,就使用该类型;否则使用范围较窄的类型别名:
const WAITLIST_AUDIENCE_ID: AudienceId = "78261eea-8f8b-4381-83c6-79fa7120f1cf"
type AudienceId = string
控制流
穷尽式判别联合。 使用 switch 根据判别字段分派,并以 throw x satisfies never 结束 default 分支。绝不使用内联三元表达式进行分派。
switch (classification.severity) {
case "critical":
return escalateCritical(event, classification)
case "routine":
return fileRoutine(event, classification)
default:
throw classification.severity satisfies never
}
不要嵌套 try/catch。 如果 catch 主体需要单独处理错误,请将 catch 主体提取为辅助函数,并在 catch 中调用它。
时间来自事件。 根据 event.triggeredAt 推导日期和时间窗口,绝不使用 Date.now() 或 new Date(),因为队列延迟、重试和重放都会导致时间偏移。时间窗口计算是普通的同步代码,不需要步骤或辅助函数。
函数式迭代。 转换数据时优先使用 map/filter/reduce,执行同步副作用时使用 forEach;谨慎使用 for 循环。当循环体中需要等待时,使用 Promise.all(items.map(...)) 并行处理,或使用 for...of 顺序等待——绝不要向 forEach 传入异步回调,因为它不会等待回调完成,还会吞掉拒绝。
优先使用 async/await,而非 .then。 使用 async/await 和 Promise 组合器(Promise.all、Promise.allSettled、Promise.race),而不是 .then() 链。
错误使用自定义类,继承 Error 并设置 this.name:
class MissingSecretError extends Error {
constructor(secretName: string) {
super(`Missing required secret: ${secretName}. Add it with \`terse secrets add ${secretName}\`.`)
this.name = "MissingSecretError"
}
}
缺失值不应凭空伪造。 当契约意味着某个值必然存在时——例如 SDK 调用的错误已处理,或架构将字段标记为必填——不要使用 ?. 或 ?? null 将缺失值伪装成数据;应抛出自定义错误,指出被违反的不变量。属于合法领域状态的缺失(例如查询未命中、确实可选的字段)没有问题,但应显式建模:将其类型标记为 | null,以便每个调用方都进行分支处理。
const { data, error } = await resend.emails.send({ from, to, subject, html })
if (error) throw new ResendApiError("emails.send", error.message)
if (!data) throw new ResendApiError("emails.send", "no data in response")
return { emailId: data.id }
文件结构
逐层展开原则。 将高层逻辑放在文件顶部(createJob 及其 onTrigger),辅助函数放在下方,让文件像报纸文章一样阅读:先呈现全貌,再介绍细节。
类型、接口和 zod 架构放在文件底部,位于实现代码之后。处理器和辅助函数引用的架构只有在任务运行时才会读取,因此放在底部是安全的。
尽量减少注释。 只有当某个选择并不显而易见、比较奇特或是有意采用的折中方案时,才添加注释。
项目布局
始终每个任务使用一个文件。 每个任务都放在 src/jobs/ 中的独立文件里,并以任务名称使用 kebab-case 命名(src/jobs/triage-bug-reports.ts);src/terse.jobs.ts 则是纯粹的副作用导入清单——即使项目只有一个任务也如此:
import "./jobs/triage-bug-reports"
import "./jobs/weekly-digest"
createJob() 通过副作用注册,因此未从入口文件导入的任务文件永远不会运行或部署。src/jobs/ 中的每个文件都必须在清单中有对应的导入行。
任务文件通过 ../terse.generated 导入生成的辅助函数。纯粹、无需步骤的辅助函数可以放在共享模块中,供多个任务使用;包含步骤的辅助函数则应留在其所属任务文件中(参见下文“将分支提取为辅助函数”),如果两个任务都需要该函数,则在各自的任务文件中重复实现。
库
对于常见问题(重试、日期计算、解析、验证),优先使用库,而不是自行实现。 选择受欢迎且维护良好的库;采用前先查看下载量和近期发布情况。
与平台集成
按以下顺序逐项检查,在首个能够完成任务的选项处停止:
-
内置的 Terse 集成,已连接 —
src/terse.generated.ts中以及terse.generated/文件夹内的所有内容(toolbox.*、Skills.*、Triggers.*)。 -
内置集成类型,尚未连接 — 使用
terse integrate connect连接,然后重新运行terse generate。 -
没有内置集成 — 使用平台官方 TypeScript SDK,先验证其是否官方:由供应商的 npm 组织发布,或在供应商的官方开发者文档/GitHub 组织中提供链接(例如
@slack/web-api、octokit、@linear/sdk、stripe)。优先使用其内置类型。 -
没有官方 SDK — 调研领先的社区封装,并让用户在以下选项中选择:
- 社区封装,并提供具体证据:GitHub 星标数、维护年限、上次发布的日期、npm 每周下载量、维护者声誉
- 手动编写带类型的 fetch 客户端,在响应边界使用 zod schema,并根据平台的 REST API 文档构建
绝不能在未告知用户的情况下采用社区封装。
凭据:对于第 2 项之后的任何情况,都应通过项目机密处理:使用 terse secrets add <NAME> 存储,在任务开头读取 process.env.<NAME>,缺失时立即抛出自定义错误。
Terse 自身的两个凭据不归你设置。运行中的任务代码使用 TERSE_PROJECT_KEY 进行身份验证;平台会将其注入每次运行,terse run/terse test 会在本地推导该值,因此绝不要将它写入 .env 或机密。TERSE_API_KEY 是 CLI 用于部署、生成和管理集成的用户令牌;它绝不能放入数据平面的环境变量中,也绝不能将项目密钥填入其中。terse secrets 会拒绝任何以 TERSE_ 开头的名称,因此如果你发现自己需要使用这类名称,说明设计有误。
标量凭据(API 密钥、令牌)按原样存储。文件形式的凭据——Google 服务账号 JSON、PEM 密钥,以及任何多行内容——都必须使用 base64 编码后,以带 _B64 后缀的名称存储,绝不能直接粘贴原文:原始 JSON 会破坏交互式提示,并让 shell 引号处理变得极其棘手;而 base64 会将值转换为一个安全的单一令牌。向用户询问文件路径,并直接从文件进行编码,这样明文就不会出现在对话中:
base64 -i service-account.json | terse secrets add GOOGLE_SERVICE_ACCOUNT_B64 --value-stdin
(在 GNU coreutils 上,添加 -w 0 以禁用换行。)
在任务开头解码,并在边界处验证任务使用的字段:
const encoded = process.env.GOOGLE_SERVICE_ACCOUNT_B64
if (!encoded) throw new MissingSecretError("GOOGLE_SERVICE_ACCOUNT_B64")
const parsed = serviceAccountSchema.safeParse(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")))
if (!parsed.success) throw new InvalidSecretError("GOOGLE_SERVICE_ACCOUNT_B64", parsed.error)
对于 _B64 机密,请在缺失机密的错误消息中提供完整的单行编码命令,以便用户可以直接复制粘贴来修复。
持久化作业风格
当作业设置 durable: true 时,这些规则适用。相关机制(重放模型、step()、jobStep、sleep、waitForInput)见 https://docs.useterse.ai/core-concepts/durability;以该文档中的说明为准。
何时使用持久化。 两类信号,分别处理:
- 强制信号——人工输入或审批(
waitForInput),或定时等待(sleep)。这些原语仅在持久化模式下存在,因此只要出现,就必须设置durable: true;无需让用户决定,只需说明这一结果。 - 判断信号——存在三个或更多会产生副作用的阶段,且中途失败会留下明显的未完成工作。这确实需要权衡:建议设置
durable: true,由用户决定。
两类信号都没有时,默认采用非持久化模式。
默认在行内使用 step()。 直接包装每个外部调用——await step(client.method(args))——让处理器呈现为依次执行的代码块。简洁的 SDK 调用(toolbox.*、generateText、state.get/state.set)本身已是持久化步骤;保持原样即可。
只有数据可以跨越步骤边界。 参数和解析后的值会被记录到日志中,因此必须可序列化:不得包含闭包、回调、流或活动对象。被调用的函数必须位于模块作用域。对于多次调用组成的单元,或接收回调的 API,应编写一个位于模块作用域的函数并包装调用:await step(sendWithRetry(args))。
jobStep({ inputSchema, outputSchema, run }) 仅用于信任边界——也就是跨越持久化边界的值需要运行时验证时;或用于必须作为一个整体记录到日志中、且无法表示为模块作用域函数调用的代码块。
将分支提取为辅助函数。 条件分支中恰好包含步骤(step()、jobStep、toolbox.*、generateText、sleep、waitForInput)时,将其提取为具名辅助函数。只计算值的条件分支则保留在行内。辅助函数放在同一文件中、作业定义下方:只有调用 createJob() 的文件才会转换 step(),因此将辅助函数移到其他文件会导致它无法正常工作。这样一来,处理器可以自上而下阅读,只有在需要时才展开查看各个分支。
根据已记录到日志的数据进行分支。 用于选择分支的条件必须取决于触发事件或步骤结果,确保每次重放都走相同的路径。
步骤之外的代码会在每次重放时重新运行。 保持其纯粹且开销低;所有副作用都必须放在步骤内。裸写在步骤外的 console.log 会在每次重放时打印一次;对于只应打印一次的日志行,请使用 terse-sdk 中的 await log(...)(一个会记录到日志中的步骤)。
示例详解
两个示例中的方法名和常量名都来自项目生成的文件;切勿自行编造。
持久化
下面的作业展示了目标持久化结构:处理器位于顶部,呈现为依次执行的代码块;穷尽式 switch 将执行分派到会产生副作用的分支辅助函数;架构和类型定义位于底部。
import { createJob, generateText, slack, sleep, waitForInput } from "terse-sdk"
import { z } from "zod"
import { Triggers, LinearTeam, SlackChannel, toolbox, type LinearIssueCreatedTrigger } from "./terse.generated"
createJob({
name: "Triage inbound bug reports",
triggers: [Triggers.linear.onIssueCreated({ team: LinearTeam.Support })],
durable: true,
filter: async event => !event.issue.title.startsWith("[test]"),
onTrigger: async event => {
const classification = await generateText({
prompt:
`Classify this bug report as critical (data loss, security, outage) or routine. ` +
`Explain your reasoning in one sentence. Context: ${event.formatForAgentRunner()}`,
skills: [],
outputSchema: Classification,
})
switch (classification.severity) {
case "critical":
return escalateCritical(event, classification)
case "routine":
return fileRoutine(event, classification)
default:
throw classification.severity satisfies never
}
},
})
async function escalateCritical(event: LinearIssueCreatedTrigger, classification: Classification): Promise<void> {
await toolbox.slack.sendMessage({
channelId: SlackChannel.OnCall.channelId,
message: `Critical bug: ${event.issue.title} — ${classification.reason}`,
thread_ts: "",
blocks: "",
})
const approval = await waitForInput({
via: slack({ channel: SlackChannel.OnCall.channelId }),
prompt: "Page the on-call engineer?",
details: { issue: event.issue.title, reason: classification.reason },
options: [
{ id: "page", label: "Page now" },
{ id: "hold", label: "Hold until morning" },
],
})
if (approval.choice === "hold") {
await sleep("8h")
}
await toolbox.linear.updateIssue({ issueId: event.issue.id, priority: 1 })
}
async function fileRoutine(event: LinearIssueCreatedTrigger, classification: Classification): Promise<void> {
await toolbox.linear.createComment({
issueId: event.issue.id,
body: `Auto-triaged as routine: ${classification.reason}`,
})
}
const Classification = z.object({
severity: z.enum(["critical", "routine"]),
reason: z.string(),
})
type Classification = z.infer<typeof Classification>
非持久化
一个完整的非持久化作业:确定性发帖、Agent 式摘要、确定性线程回复。
import { createJob, generateText } from "terse-sdk"
import { Triggers, Skills, Repos, SlackChannel, toolbox, type GithubPROpenedTrigger } from "./terse.generated"
createJob({
name: "Summarize PR and notify Slack",
triggers: [Triggers.github.onPROpened({ repo: Repos.MyOrg.MyRepo })],
filter: async (event: GithubPROpenedTrigger) => {
return !event.sender.login.includes("[bot]")
},
onTrigger: async (event: GithubPROpenedTrigger) => {
// Deterministic: fixed channel, fixed opener — use toolbox, no agent needed
const message = await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: `New PR from ${event.sender.login}: ${event.pullRequest.title}`,
thread_ts: "",
blocks: "",
})
// Agentic: only the summary needs judgment
const summary = await generateText({
prompt:
`Summarize the changes in this PR. ` +
`Focus on what changed, why it matters, and what reviewers should look at first. ` +
`Keep it concise. Context: ${event.formatForAgentRunner()}`,
skills: [Skills.github({ repos: [Repos.MyOrg.MyRepo] })],
})
// Deterministic: post the result back in thread
await toolbox.slack.sendMessage({
channelId: SlackChannel.Engineering.channelId,
message: summary,
thread_ts: message.message_ts,
blocks: "",
})
},
})