mcpField
T
terse-improve

terse-improve

github.com/terseai开发37 次安装
代码仓库

关于 terse-improve

用于改进指定 Terse 工作流的技能,通过检查实现、生成的集成定义和生产运行记录来分析并验证变更。面向维护 Terse job 的开发者,强调确定性工具调用、安全测试和过程说明。

主要功能

  1. 01检查 job 代码与生成的集成定义
  2. 02查看生产运行记录
  3. 03审查工具用法与提示词
  4. 04实施并验证工作流变更
  5. 05说明命令行运行及测试结果

适用场景

  1. 01诊断失败或取消的工作流运行
  2. 02过滤无关触发事件
  3. 03将 Agent 操作改为确定性调用
  4. 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 平台:

  1. 项目本身的代码(如果存在):(src/terse.jobs.ts、`src/jobs/)
  2. 生成的文件应作为主要事实来源,并说明 Terse 的工作方式:src/terse.generated.ts 是入口文件,其文件头列出所有可用集成,并附有 src/terse.generated/ 文件夹的行号索引。该文件夹中每种集成和类型各有一个文件(用于触发器工厂和负载类型的 <integration>.triggers.ts、用于类型化工具方法、资源和技能的 <integration>.tools.ts、如 attio.schemas.ts 这样的工作区架构文件,以及 common.ts 中的共享基础设施)。请使用索引,只打开作业涉及的文件。生成的文件说明了现有内容及其确切结构;如果它们与在线文档不一致,以生成的文件为准——如果缺少辅助函数,就重新运行 terse generate,切勿依据生成的文件不支持的文档说法编写代码。
  3. Terse CLI:(terse --help)
  4. 最后才考虑使用 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,请在现有作业中运行读取操作:

  1. 在作业处理器顶部插入一个连续代码块,用于执行读取、记录结果,并以 return 结束——其下方的代码都不会执行。
  2. 使用 terse test run 运行,这样密钥注入方式就与真实作业完全一致。
  3. 记录发现的内容,然后删除该代码块,并使用 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 / outputSchema
  • generateText 的 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 导入生成的辅助函数。纯粹、无需步骤的辅助函数可以放在共享模块中,供多个任务使用;包含步骤的辅助函数则应留在其所属任务文件中(参见下文“将分支提取为辅助函数”),如果两个任务都需要该函数,则在各自的任务文件中重复实现。

库

对于常见问题(重试、日期计算、解析、验证),优先使用库,而不是自行实现。 选择受欢迎且维护良好的库;采用前先查看下载量和近期发布情况。

与平台集成

按以下顺序逐项检查,在首个能够完成任务的选项处停止:

  1. 内置的 Terse 集成,已连接 — src/terse.generated.ts 中以及 terse.generated/ 文件夹内的所有内容(toolbox.*、Skills.*、Triggers.*)。

  2. 内置集成类型,尚未连接 — 使用 terse integrate connect 连接,然后重新运行 terse generate。

  3. 没有内置集成 — 使用平台官方 TypeScript SDK,先验证其是否官方:由供应商的 npm 组织发布,或在供应商的官方开发者文档/GitHub 组织中提供链接(例如 @slack/web-api、octokit、@linear/sdk、stripe)。优先使用其内置类型。

  4. 没有官方 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: "",
        })
    },
})

评论

支持 Markdown