Matt 的 to-tickets + implement 优化
不要让人当调度器
从 Matt Pocock Skills 的
/to-tickets+/implement说起。一句话结论:工单是给模型的上下文单位,不应该是人的调度单位。当 N 变大,把编排从模型手里拿走,交给确定性代码。
目录
1. 一个具体的下午
设想一个常见的场景:一个功能拆成 8 张 ticket,放在 .scratch/<feature>/issues/。按照 Matt 的流程,接下来是这样:
/clear
/implement 01
# tdd → 类型检查 → 单测 → 全量测试 → /code-review → commit
/clear
/implement 02
...
前 3 张很顺。到第 5 张,问题开始显现,而且不是某一张票的问题,是流程的形状:
- 每张票结束都跑一次全量
flutter test; - 每张票结束都调一次
/code-review,它内部再开两个并行子 agent; - 哪张票能跑、哪张票被阻塞、哪张票失败后要跳过哪些下游,全靠你盯着文件;
- 每张票之间要
/clear,你得把上一张票的结论压缩成一句话带进下一张。
这不是 Matt 的设计缺陷。恰恰相反,这是他的设计目标:skills 不接管你的流程。/implement 的 frontmatter 写着 disable-model-invocation: true,意思很明确——由人发起,由人决定下一步。你保留控制权,代价是:人就是调度器。
3 张票时,人当调度器很优雅;10 张票时,人成了吞吐量的上限。
3. 三笔成本
3.1 每票一次 review:2N 个审查上下文
/code-review 不是“扫一眼 diff”。它固定开两个并行子 agent:
- Standards:对照仓库规范 + Fowler 坏味道基线;
- Spec:对照原始 issue/spec,找缺失、越界、实现错误。
于是 N 张票 = N 次 /implement = 2N 个 review 子 agent。8 张票就是 16 个审查上下文。
成本不只是 token:
- 重复装载:每个 Standards 子 agent 都要重新拿到同一份仓库规范和坏味道基线;每个 Spec 子 agent 都要重新拿 spec。
- 审查的是中间态:第 2 张票的 review 通过后,第 5 张票可能又改了同一片代码。被审过的状态根本不会成为最终状态。
- 单票视角:Spec 轴只拿这一张票对照 spec,看不到“票 03 推翻了票 01 的决定”“两张票给同一个概念起了两个名字”“共享文件在两张票里各改了一半”。
- 反馈浪费:review 发现的问题,可能在下一张票里被顺手改掉,也可能被下一张票重新引入;你为同一个问题付了两次 review。
Matt 的 /implement-spec 把 review 改成“全部票合并后,对 integration branch 跑一次”。这解决了 2N,但把反馈推到最后:文档里有一个 5 张票的案例,review + fix 循环跑了大约 4 小时;中途任何一张票的问题,要等到最后才暴露。
我们的选择是中间态:按依赖层 review。一层里的票全部完成后,对 layerBase..HEAD 做一次 review。它看到的是一批已经提交、彼此一致的改动;层内的票共享同一个 review 上下文,跨票的命名冲突、共享文件冲突、spec 覆盖缺口才有机会被发现。review 的修复单独提交,不污染票的 commit。
如果依赖图是一条纯线性链,层 review 的次数和票 review 一样多;省下来的是全量测试和人工调度。层 review 的真正收益出现在一层有多张票时。
3.2 每票一次全量测试:N × T
/implement 的原文:
Run typechecking regularly, single test files regularly, and the full test suite once at the end.
对单张票,这是合理的:全量测试是最后的安全网。
但把这句话放进 N 张票的循环,它就变成 N 次全量测试。这个 Flutter 仓库有 159 个测试文件,全量 flutter test 是分钟级的;8 张票就是 8 个“分钟级”串行堆在一起。更糟的是:
- 第 6 张票改动后,第 1 张票的全绿已经不代表当前 HEAD;
- 大部分时间里,你重跑的是与当前票无关的测试;
- 真正的全局回归,只有在最后一次全量测试时才有意义。
我们的做法是把全量测试从“每票的收尾动作”改成“整批的验收动作”:
- 每张票只跑受影响测试:
scripts/affected_tests.dart从变更文件出发,取“变更测试 + 镜像测试 + 反向 import 闭包 + 守卫测试”的并集;改动命中资源、依赖、本地化生成物、应用入口、高扇出文件,或选中比例超过 60% 时,自动升级为全量; - 全量测试在 Verify 阶段每个涉及实现的仓库跑一次,作为硬门禁;
- 单票测试仍然要跑,失败了就地修,不用等到最后。
这是一笔明确的交换:用“可能漏选”的风险,换掉 N-1 次全量测试的时间。漏选由最后那次全量兜底;如果你不能接受“问题晚发现”,打开 perTicketFullSuite,回到每票全量——工作流把这个选择留给你,而不是写死。
3.3 多票依赖靠人调度:N 次人工交接
/to-tickets 只负责把 Blocked by 写进文件;ask-matt 的原话是:
worked blockers-first by hand
真正执行时,人是调度器:
- 读所有票,算出当前 frontier;
- 决定这一轮跑哪几张;
- 每张票
/clear后新开上下文; - 等它跑完,检查结果,更新 status;
- 一张票失败,判断哪些下游要跳过;
- 想并行,自己开多个会话,同时祈祷它们不要改同一个文件;
- 跨仓库时,还要分别在每个仓库切分支、提交、对账。
8 张票就是 8 次人工交接。人成了吞吐量的上限,而且每次交接都可能丢上下文、漏票、重复跑。
这里有个更隐蔽的问题:人脑里的调度器没有持久化。你记住的 frontier、跳过原因、失败传播,出了这个下午就没了;下一次重跑,要从文件里重新拼回来。Blocked by 是给读文件的人看的,不是给执行器看的。
/implement-spec 确实把 frontier 自动化了:它把票当 task graph,在 ready frontier 上并行起 implementer 子 agent,每个子 agent 在自己的 worktree 里干,最后合并到 integration branch。但它把调度交给了另一个 agent,也就继承了 agent 调度的不确定性:
- worktree 不消除文件冲突,只是把冲突推迟到 merge;blocking edge 是对“哪张票会改哪些文件”的猜测;
- GitHub 上 blocked-by 计数只在 blocker 关闭时下降,中途 frontier 可能是过期的;
- 所有票通过 merge 落在同一条 integration branch 上,历史是合并图,不是每票一个线性 commit;
- 一张票失败,整条分支的状态需要人来判断怎么退。
我们的选择是把调度写进代码:确定性的依赖图、确定性的分层、确定性的并行/串行判定。模型只负责实现和审查,不负责决定“下一张跑哪张”。
一笔示意账(假设 N=8,全量测试 T=3 分钟):
| 项目 | 逐票手动 | 本工作流 |
|---|---|---|
| 全量测试 | 8 × T ≈ 24 分钟 | 1 × T ≈ 3 分钟 |
| review | N 次 / 2N 个子 agent | L 次层 review / 2L 个子 agent(示例 L=4) |
| 人工交接 | 8 次 | 0 次常规交接 |
仅示意测试与 review 两项,不是基准测试;实际 wall-clock 还受并行度、agent 速度、失败重试影响。
4. 流水线:把编排从模型手里拿走
我们的 workflow 在 .claude/workflows/implement-tickets.js,1975 行,六个阶段:
Plan → Branch → Implement → Review → Commit → Verify
4.1 Plan:只读 ticket 与 spec
planner 被严格限制为只读,而且不允许读源码。它只从 ticket 顶部的 Status:、Blocked by:、Files: 提取信息;读不到就标 unknown,不猜。
然后:
- 按
Blocked by入度做拓扑分层(Kahn); - 同层内,只有显式声明
Files:且路径不相交的票才允许同仓库并行; - 路径重叠的票用并查集合并成串行链;
unknown票与同仓库其他票保守串行;- 可选的 scope pass 只补全
unknown的范围,永远不用于解锁并行——因为它不是 ticket 的显式声明。
这条规则很关键:并行不是“模型觉得可以”,而是“ticket 自己声明了不相交的文件”。模型可以猜错;文件声明可以审计。
L1 01 数据层 02 文案层
│ │
▼ ▼
L2 03 页面接入 04 埋点
└───────┬────────┘
▼
L3 05 联调验收
这也解释了 Matt 的 to-tickets 与我们的 Files: 之间的张力:Matt 明确建议不要写具体文件路径,因为它们会过期。我们的回答是:Files: 不是实现指令,是调度元数据;它只用于判断能否并行,过期的代价是退化成保守串行,而不是实现跑偏。
4.2 Branch:每个仓库一条 spec 分支
每次运行从 feature 目录名派生 spec/<feature>,在根仓库和所有涉及的子仓库里创建或复用。切换前记录 baseBranch / baseHead,切换后回读校验。分支名不仅过 git ref 规则,还拒绝 shell 元字符——因为它会嵌进 agent 的 shell 命令。
4.3 Implement:每票一个子 agent
实现者拿到的是:
- 本票、spec、
GLOSSARY.md、相关 ADR; - 只跑受影响测试的指令;
- 不调用 code-review 的指令(review 由层统一做);
- 不执行任何 git 写命令的指令(提交由 workflow 单独做)。
每张票完成后,ticket 的 Status: 改为 resolved,并在 ## 评论 追加一行带日期的结论。
4.4 Review:每层一次
一层全部完成后,每个仓库开一个 reviewer,审查 layerBase..HEAD 的提交范围。review 的修复单独提交,不混进票的 commit。这样:
- review 看到的是这一层所有票的合并结果;
- 票的 commit 保持干净,方便 revert;
- review 的修复有自己的 commit,方便追溯。
4.5 Commit:每票一个 commit
默认 per-ticket:
- 提交前先校验当前分支等于期望分支;
- 只
git add本票返回的文件,禁止git add -A; - commit message 带
Ticket: <id>,便于审计和git log --grep; - 代码仓库与 tracker 仓库分离时,tracker 单独提交
chore: 标记 <id> 已实施; - 提交失败,或并行时 scope 违约,隔离整个仓库,后续票不再提交。
这一条是 /implement-spec 的 integration branch 给不了的:一条线性、每票一个、可直接回滚的 commit 历史。commit 不只是给 git 看的,它是给人用的 API:git revert、git cherry-pick、git bisect、以及事后回答“这张票到底改了什么”,都依赖它。
4.6 Verify:一次性验收
- 每个涉及实现的仓库跑一次全量测试;
- 校验分支;
- 审计每个 commit:sha 存在、是 HEAD 的祖先、提交的文件没有超出声明;
- 校验 ticket status 与期望一致;
- 校验提交落点:Flutter 代码必须在
pindoudou,后端代码必须在根仓库。
5. 一张对照表
| 维度 | /implement(逐票手动) |
/implement-spec(agent 编排) |
implement-tickets(本工作流) |
|---|---|---|---|
| 调度者 | 人 | 编排 agent | 确定性脚本 |
| review 粒度 | 每票 1 次(2 个子 agent) | 整条 integration branch 1 次 | 每层每仓库 1 次 |
| 测试粒度 | 每票全量 | 每票 tdd;全量策略未规定 | 每票相关测试 + 验收全量 1 次 |
| 提交粒度 | 每票 1 个 commit | merge 到单条 integration branch | 每票 1 个 commit |
| 并行依据 | 人肉判断 | blocking edges + worktree | 显式 Files: + 路径不相交 |
| 冲突处理 | 人肉避让 | merge 时解决 | 并查集串行链,提前避让 |
| 失败传播 | 人肉判断 | agent 判断 | 依赖图自动跳过 + 仓库隔离 |
| 跨仓库 | 手动 | 未覆盖 | 根仓库 + 子仓库统一分支/提交 |
| 可重跑 | 手动 | 手动 | only / includeResolved / dryRun |
| 人工介入 | 每票 1 次 | run 结束时 | 失败时 / 结束时 |
表格里的
/implement-spec一列来自它的公开文档;它假设 harness 能为每个子 agent 提供 worktree。我们的 workflow 运行在 Workflow 工具里,所有 agent 共享同一个工作区,所以并行必须靠显式文件范围提前避让——这是另一种约束,不是免费的午餐。
为什么不是直接用 /implement-spec?
它很近,方向也对。差别在于优化目标:
- 它优化“一次落地”:一条 integration branch,最后统一 review,适合“我只想要一个 PR”的场景;
- 我们优化“可运营”:每票一个 commit,失败可隔离,断点可重跑,跨仓库可对账,适合“这批票要跑好几天、中途会有人接手”的场景;
- 它把调度交给 agent,能处理意外;我们把调度交给脚本,能保证确定性。两者不是替代关系,是不同 harness 假设下的不同取舍。
如果你的场景是 3 张票、一条分支、一个 PR,/implement-spec 更简单。如果你的场景是 10 张票、多个仓库、每票要能单独回滚,本文这套更合适。
6. 取舍:这套东西什么时候不值得
- 脚本本身就是成本:1975 行编排逻辑,调试它需要读代码。3 张票以下,直接用
/implement更划算。 - ticket 必须写
Files::不写就退化成保守串行,并行收益消失;写了但过期,也会退化成串行。它不会让实现跑偏,但会拉长 wall-clock。 - 全量测试推到 Verify:如果受影响测试选漏了,问题会晚发现。最后那次全量是兜底,不是即时反馈。
- 层 review 比票 review 晚:层越大,反馈越晚;如果一层里有一张票错得很离谱,你要等到整层完成才知道。
- planner 是模型:它可能解析错依赖,scope pass 也可能失败。所以我们保留
dryRun、显式 warnings、simulateFail/simulateCommitFail。 - 同仓库并行不是免费的:生成物、锁文件、Flutter 工具锁仍可能成为隐式共享资源;默认只对显式声明不相交文件的票开启,
unknown一律串行。 - 不要用:1–3 张票、探索性工作、需求还在变、没有明确文件边界。
7. 结论
Matt 的 skills 是“不拥有你的流程”;我们的 workflow 是“明确拥有批处理的那一段流程”。两者不矛盾:
- 把判断留给模型:实现、审查、修 bug;
- 把编排交给代码:依赖图、分层、并行、scope、提交、重试、审计。
当 N=3,人当调度器没问题;当 N=10,调度器应该是一个可读、可测、可重跑的程序。
工单是给模型的上下文单位,不应该是人的调度单位。
附录:怎么跑
Workflow({
scriptPath: '<repo>/.claude/workflows/implement-tickets.js',
args: {
featureDir: '.scratch/<feature>',
rootRepo: '/path/to/repo',
dryRun: true,
},
})
dryRun 输出分层、并行机会、unknown scope、跳过原因。先看计划,再决定要不要真跑。
常用参数:
| 参数 | 作用 |
|---|---|
only: ['01','02'] |
只跑指定票;可重跑 resolved |
includeResolved |
重跑已 resolved 的票(幂等核对) |
maxTickets |
限制本次执行数量 |
perTicketFullSuite |
每票跑全量(默认只在 Verify 跑一次) |
sameRepoParallel |
explicit / known / off / all |
strictScope |
auto / warn / fail / off |
simulateFail / simulateCommitFail |
验证失败传播与隔离 |

浙公网安备 33010602011771号