← 返回博客2026-07-30

文档里的自动化,不会自己跑起来

两份文档都写着每日 heartbeat 会呈现博客节奏的过期提醒,并引用了一个不存在的章节号。翻开历史才发现:这条接线是被有意搁置的,理由老老实实记在了 commit message 里——而文档用现在时描述了同一件事。之后 59 个分支合进 main,没有任何东西报警。搁置的工作一旦用现在时写下来,就不再是计划,而是断言。

问题

这个博客的节奏目标是两天一篇。它停了六天。这不值一提。值得一提的是:仓库自己的文档一直写着每日 heartbeat 会把这种停摆报出来——而且是用现在时写的,从这条接线被刻意搁置的那一天起就这么写着。

这套节奏系统由两个真实存在的部分组成:

  • 一个挖掘脚本。scripts/blog/blog-candidates.mjs 读文章清单、算距上一篇多少天,过期时还会从近期的 merge 里挖出候选选题并排序。它能跑,现在也照样能跑。
  • 一条写稿流水线。一个 skill,给它选题就能产出成品双语文章:挖料、成文、逐条对着仓库核事实、构建验证、评审、落地。这条流水线同样能用。

而把这两部分接起来的那个东西——那个会按时去跑挖掘脚本、并根据结果采取行动的角色——不存在。不是「坏了」,是压根没有。

但它被文档写得明明白白。两个组成部分都指向它。skill 里写着:

目标两天一篇。node scripts/blog/blog-candidates.mjs 会报距上一篇的天数,过期时给出从 merge 里挖来的候选选题排名。每日 heartbeat 也会呈现同样的信息(HEARTBEAT-SOP §7.1)。

挖掘脚本自己的文件头也重复了一遍,还列出了它的两个消费方:写稿 skill,以及「每日 AI-heartbeat(HEARTBEAT-SOP §7.1)」。两个文件,口径一致,用现在时描述着一条运转中的集成。

而在修复前的那个 commit 上,它们共同引用的那份文档,真实情况是这样的:

docs/ops/HEARTBEAT-SOP.md出现次数
字符串 7.10
blog 一词(不分大小写)0
任何带编号的标题0

至于这两个文件点名的执行方?scripts/ai-team-manager.mjs——每轮巡逻真正会跑的那个脚本——全文出现「blog」的次数是 0。它当时只跑两条常驻职责检查,没有一条是这个。

文档描述的样子 挖掘脚本blog-candidates.mjs写稿流水线/blog skill 每日 heartbeat “HEARTBEAT-SOP §7.1” 用现在时写 · 写在两个文件里 · 却是被刻意搁置的 代码实际的样子 挖掘脚本能跑,但要人手动跑写稿流水线能跑,但要人手动跑 什么都没有 巡逻脚本里 “blog” 出现 0 次

两部分都真实存在,也都能跑。虚构的只有中间那两根箭头——而箭头恰恰是用文字写的那部分。

上一篇文章落地之后,又有 59 个分支合进了 main——总共 248 个 commit——这个缺口才被查了出来。没有任何一次触发了什么,因为压根没有可触发的东西。

真正值得写下来的那部分

我的第一反应是:谁把章节号打错了。要真是这样,这就是个无聊的 bug。真相要有用得多——而我能找到它,只是因为一个评审方拒绝接受我的第一版解释,跑去把 git 历史翻了一遍。

有那么一个 commit,它同时做了三件事:造出挖掘脚本、造出写稿 skill、把 §7.1 这条引用写进这两处。而这同一个 commit 的说明里,白纸黑字写着:

Heartbeat SOP §7.1 的接线属于特权文件改动(受闸门管控)——留待人工明确签核;在那之前这些工具可以独立使用(/blog auto、--nudge)。

所以这条引用从来就不是笔误。它是一次有意的搁置,判断正确,而且诚实地记录了下来。那份 SOP 是受保护文件——定义了自动化的各道闸门意味着什么的那一类——把职责接进去需要一个当时还没拿到的人工签名。作者知道这件事。作者也写下来了。

作者把它写在了 commit message 里

而在文档里,同一件事是用现在时描述的:每日 heartbeat 也会呈现同样的信息。一个月之后,这两份记录里,只有一份还留在日常工作流会读到的地方;另一份得有人专门去找才拿得到。平心而论,通路是有的——对着那一行跑 git blame,落点正好就是那个 commit。只不过你得先怀疑这句话有问题才会去跑——而一句听起来笃定的话,恰恰最不容易引起这份怀疑。

接下来又悄悄发生了两件事。签核始终没来。以及,SOP 后来被重构过——在那条引用写下的时候,它确实是用带编号的标题的,其中就有一节 ## 7. §7 Emit ONE bilingual digest,所以 §7.1 当时指的是一个存在的章节下面一个说得通的位置。六天之后,一次改写把这些编号标题换成了纯名字(## Operating Principle## Entry Points## State Machine),原来的锚点也随之消失。等到有人真去看的时候,这条引用已经断了两道。

抽象成一句话——这也是它能脱离我们这个仓库、对别人依然成立的原因:

用现在时描述的、被搁置的工作,就不再是计划,而是断言。搁置的理由留在 commit message 里,只有想到要去翻的人才拿得到;而那句断言留在文档里,正是日常工作流会读的东西。

那么具体该怎么做:

  • 被搁置的集成,要用将来时写,写在文档本身里,并且点名卡在哪。「等 SOP 那一条签核后,heartbeat 将会呈现这个」——这句话放久了会变成一个显眼的疑问。「heartbeat 呈现这个」放久了会变成一句谎话。改时态并不能把缺失的那个消费方造出来,但它是成本最低的一步补救,而且能在有人真去造之前,先让文档保持诚实。
  • 永远别让 commit message 成为某个注意事项的唯一栖身之处。如果一条限制在一个月后依然要紧,那它就该待在描述行为的地方——或者待在一个测试里。历史是能查的,但除非读者已经在怀疑那一行、并主动去跑 blame,否则日常工作流不会把他引过去。
  • 引符号,别引章节号。原理上两者都能机械校验——上面那张「出现 0 次」的表本身就是一次机械校验。区别在于什么是现成的checkBlogCadence 可以直接用现成的语言工具链跳到定义或调用点——任何支持语言分析的编辑器都自带这个能力;而纯文本的 §7.1 需要有人专门写一个检查器,并且文档一重新编号它就无声断掉——这正是实际发生的事。
  • 接手任何「两部分式」系统,先去搜调用点,别信描述。「生产者存在,消费者写在文档里」是一种高风险组合,因为评审时一切看起来都齐了。

最后落地的东西

巡逻脚本本来每轮就跑两条常驻职责检查,每条只返回三种结果之一——已满足、生成一条待派发的 finding、或者阻塞。博客节奏成了第三条。它以文章清单里最新的日期为准,过期就发 finding:

function checkBlogCadence({ postsFile, today = localDate(), staleDays = BLOG_STALE_DAYS } = {}) {
  if (!postsFile) return { blocked: 'blog posts registry path unresolved' }
  let src
  try {
    src = fs.readFileSync(postsFile, 'utf8')
  } catch (err) {
    return { blocked: `blog posts registry unreadable: ${err.code || err.message}` }
  }
  const dates = parsePosts(src).map((p) => p.date).filter(isRealIsoDate)
  if (dates.length === 0) {
    // 解析出 0 篇带日期的文章,说明正则和 posts.ts 的结构脱节了,
    // 而不是说明博客是新鲜的 —— 拒绝把这条职责报成绿灯。
    return { blocked: `blog posts registry parsed 0 dated entries: ${postsFile}` }
  }
  const latest = dates.sort().at(-1)
  const ageDays = daysBetweenUTC(latest, today)
  if (!isBlogStale(ageDays, staleDays)) return { ok: true }
  return { finding: /* … 派发出去,并在证据里点名挖掘脚本作为选题来源 … */ }
}

比正常路径更重要的是那几个 blocked 分支。一条读不到输入的职责检查必须报未知,绝不能报已满足——否则第一次文件系统抽风,就会把「我不知道」悄悄换成「一切正常」。零条目那个分支更尖锐:万一清单结构被改到解析不出来,诚实的答案是「我的解析器坏了」,不是「博客很新鲜」。

然后,这个修复自己把 bug 又犯了三遍

这个补丁经过三轮带 finding 的独立评审,第四轮才以空清单通过。其中三条 finding 和最初那个 bug 属于同一种失效模式——文档断言了一种没有代码去执行的关系——只是换了身衣服。

1. 我把它搁置在了同一道闸门后面,还用了同样的现在时

我的第一版补丁把那条死掉的 §7.1 换成了听上去很活的一条:见 HEARTBEAT-SOP「Standing Proactive Duties」第 3 条。并没有第 3 条——因为那份 SOP 是受保护文件,而我同样没拿到签核。我等于是在一个立论完全建立在「这个错误已经被犯过一次」之上的补丁里,出于一模一样的原因,把这个错误重犯了一遍。评审的原话是:「这个补丁重现了它声称要修的那个悬空引用失效模式。」

它没有进到 main——在分支上就被抓住并改掉了。改法是:指向真正执行这条职责的代码,并且在文档里直白写清 SOP 那一条仍在等签核。如果你的改动里有一步被卡住了,那它不是「变小了」,而是「不完整」——而这份不完整该写进文档,不该只留在你脑子里。

2. 共享 helper ≠ 共享行为

为了让职责检查和挖掘脚本对「过期」的理解不打架,职责检查 import 了挖掘脚本的解析器和日期算法。那行 import 上面还写了注释,说因此不可能漂移。它能漂移。它就漂了。

「过期」的定义第几天触发吃环境变量吗
挖掘脚本:daysSince >= STALE_DAYS2
职责检查(初版):ageDays <= 23不吃

于是在文章刚好满两天的那一天,手动跑挖掘脚本会打印 🟡 STALE,而同一小时的巡逻在日志里写着常驻职责均正常(…… + 博客节奏)。两个组件,一个 import 另一个,共享着解析器,却在唯一要紧的那个问题上互相矛盾。挖掘脚本认的那个环境变量覆盖,职责检查完全无视。

修法是把判定本身导出去,而不是零件,两边都用它。共享「一个判断所依赖的 helper」并不等于共享了那个判断。如果两个组件必须对某个判断保持一致,那就把判断本身导出去。

3. 一个默认契约会拒绝这份活的 owner

这条职责一开始派给了已经在管另一条内容职责的那个角色。那个角色的章程写明它不写 production code,而它的产出契约由一个 hook 强制执行,该 hook 要求提供漏斗与实验证据——写一篇博客根本产不出这种东西。这一版要是真上线了,每一条 finding 都会默认被派给一个自身契约有可能拒收这份活的角色。

豁免其实是有的:那个 hook 提供了一个写在文档里的单次跳过开关。但用它就意味着以后每一次博客交付,都得依赖一个原本为其他场景准备的例外开关,所以改的是 owner,换成一个被允许干这活的角色。一个默认会被你自己护栏拒绝的派活,和一条指向不存在章节的引用是同一种失效——都是关于系统行为的、没人执行的断言。

诚实的边界

  • 它不负责把文章写出来。它做的是把静默漂移变成一条可见的、被派发的、每小时都会重提的 finding。活还是得有人干——人或者 agent——如果这个派活被无视,节奏照样会滑。检测不等于产出。
  • SOP 那一条在起草这篇时还在等签核,现在已经签了。有大约一天时间,职责检查在跑,而那份受保护运营文档里并没有描述它——正是第 4 节讲的那个缺口。这次不一样的地方在于那句注意事项写在哪:写在文档里、写在读者会经过的地方,而不是埋在 commit message 里。这恰恰就是本文的全部主张,也正因如此,这句话才有机会被改成现在这样,而不是无声地烂在那儿。
  • 我们没有做出那个通用的修复。新增的测试覆盖的是这条职责的行为——阈值、派发、阻塞态、清单解析。没有任何一条会去读文档、并断言它点名的那个符号确实存在。本文主张的那个「文档→代码」引用检查器,并没有做出来,仓库里其余的交叉引用我们也没有审过。
  • 抓到这些的是评审,不是测试。这里其实是两次评审各做了一半:对职责补丁的独立评审,在它落地之前抓出了第 4 节那三个缺陷;而针对这篇文章的事实评审,又跑去翻了历史,推翻了我第一版的根因解释——我原本把它写成了「有人把章节号打错了」。强制独立评审是一道真实存在的流程,它两次都起了作用。两次缺的都是同一样东西:任何机械化的检查。

回顾

  1. Bug:一个系统的两个组成部分都能跑,它们之间的集成被写在两份文档里,而没有任何代码把它们连起来。
  2. 根因,而且不是马虎:这条集成是被有意搁置在一道人工签核闸门后面的,理由记在了 commit message 里。而文档用现在时描述了同一件事。一个月之后,这两份记录里,日常工作流仍会读到的只有其中一份。
  3. 成本最低的补救:用将来时,写在文档里,点名卡在哪。「等 X 签核后将会……」放久了变成一个疑问;「……」放久了变成一句谎话。它并不能把缺失的消费方造出来,只是让文档在你动手之前别再说谎。
  4. 值得长期保留的习惯:引符号,别引章节号。两者都能机械校验,但只有一个自带现成的工具链,也只有一个扛得住重新编号。我们那条断了两道:接线始终没落地,六天之后它引用的那批编号标题又被改成了名字。
  5. 同一次事故里的另一条规矩:如果两个组件必须对某个判断保持一致,那就导出判断本身,而不是构成它的零件。