我平时的干活方式是:同时开两到四个 AI 编码会话,让它们并行推进不同的事。很快我就撞上一个所有"人 + 多 agent"团队都会撞上的问题------这些会话互相不知道对方在干什么。两个会话认领了同一个任务、一个会话崩了之后任务永远卡在"进行中"、状态改来改去最后没人知道哪个是对的。
第一反应是上数据库、起个服务、加把分布式锁。第二反应是:一个单人工作流,搞这些是不是有病?最后我用一个文件夹的 markdown 文件 解决了,顺手做成了一个开源小工具 huntbook(v0.1 已发 npm)。这篇文章讲它的协议设计------不聊产品,聊"文件系统当数据库"用在这个场景下,每一个决定是怎么做的。
一、先把问题摆正:AI 会话是没有共享记忆的同事
AI 编码会话(Claude Code、Cursor 之类)的协作模型有个特点:每个会话都是无状态 的------上下文关了就全忘。人类团队的解法是"大家看同一个看板",但 AI 会话不会自己去打开网页,它只认两样东西:你发给它的文本 ,和它在磁盘上读到的文件。
所以"给 AI 会话一个工作流"本质上是设计一个协议:状态放在哪、怎么读、怎么改、怎么防止两个写者打架。而协议的载体必须是文件------因为文件是 AI 会话唯一可靠的共享记忆。
这也是为什么我不想用数据库。不是嫌重(虽然确实重),是因为数据库对这个协议的读者不友好 :AI 会话读 PostgreSQL 里的状态,还得经过一层查询工具;读 markdown 文件,cat 一下就完了,而且它天然擅长读 markdown------训练语料里全是。
二、.huntbook/:整个工作台就是一个文件夹
huntbook init 之后,磁盘上多出来的东西就是全部状态:
yaml
.huntbook/
├── board.md # 看板(派生物,可随时重建)
└── cases/
├── 2026-08-28-acme-com-login-rate-limit-bypass.md
└── ...
每个任务(case)是一个 md 文件:frontmatter 存字段,正文存笔记:
yaml
---
id: 2026-08-28-acme-com-login-rate-limit-bypass
target: acme.com
title: Login rate limit bypass
severity: high
grade: A
status: doing
claimed_at: 2026-08-28T09:12:00.000Z
---
## Notes
(自由格式的过程记录)
这个结构的直接红利:git init 它,你就同时拥有了备份、同步、历史审计和多人协作 。换台机器,git clone 一下,整个作战现场原样恢复。没有迁移、没有 dump/restore、没有"数据库连接串"。哪天不用了,删文件夹就是卸载。
有同行会问:SQLite 不也是单文件吗?是,但它对 git 不友好(二进制 diff 灾难),对 AI 会话不友好(要过查询层),对人类不友好(打开看不见内容)。三个读者里 markdown 全赢,才敢不引数据库。
三、防打架:90 分钟认领窗口
核心问题来了:两个会话(或者你 + 一个会话)怎么不抢同一个任务?
数据库方案是 SELECT ... FOR UPDATE。文件方案是把这个锁降级成一个时间戳 :认领任务时,把 status 改成 doing 并写入 claimed_at。规则只有一条------doing 状态的任务,90 分钟内不进候选队列:
arduino
/** Two agent sessions must not fight over the same target inside this window. */
export const REENTRY_MINUTES = 90;
huntbook next(领取下一个任务)的核心判定逻辑:
ini
export async function nextCase(root: string, now = new Date()): Promise<NextPick | null> {
const cases = await listCases(root);
// 1. 有没过期的认领 → 吸附回去,别开新目标
const freshDoing = cases.find(
(theCase) =>
theCase.status === "doing" &&
theCase.claimedAt !== null &&
now.getTime() - Date.parse(theCase.claimedAt) < REENTRY_MINUTES * 60_000,
);
if (freshDoing) return { doc: freshDoing, resumed: true };
// 2. 候选 = todo + 已过期回收的 doing
const eligible = cases.filter(
(theCase) =>
theCase.status === "todo" ||
(theCase.status === "doing" &&
(theCase.claimedAt === null ||
now.getTime() - Date.parse(theCase.claimedAt) >= REENTRY_MINUTES * 60_000)),
);
// 3. 领取 = 写回文件(status + claimed_at)
const pick = eligible[0];
if (!pick) return null;
pick.status = "doing";
pick.claimedAt = now.toISOString();
await writeFile(casePath(root, pick.id), caseToMarkdown(pick));
return { doc: pick, resumed: false };
}
三个设计点值得展开:
认领窗口是"惰性过期"的。 注意整个库里没有任何定时器、没有守护进程、没有 cron。一个卡死的 doing 任务不会被谁"释放",它只是在你(或下一个会话)下次调 next 时,被那行 >= REENTRY_MINUTES * 60_000 判定为过期,自动回到队列。状态机的推进永远搭在下一次事务上,而不是搭在后台进程上------这是文件协议最重要的一课:你的数据躺着不动,逻辑跟着读写走。
窗口内是"吸附"语义,不是"拒绝"语义。 第二个会话在窗口内调 next,拿到的不是报错,而是那个正在进行的任务本身,输出前缀是 still on it (claimed ...)。这是有意的:单人 + 多会话的规模下,正确的协作姿态是"一个时间窗内全团队聚焦一件事",而不是各自漂移去开新战线。会话读到这个前缀,就知道该接着当前目标干,而不是换人。
为什么是乐观窗口而不是真锁? 因为写者的真实规模是"1 个人 + 2~4 个会话,写冲突的概率本来就近乎零"。锁要解决的问题(高频并发抢占)在这个规模下不存在;这里真正要防的是会话崩溃后的死锁 和人类忘记释放------这两个恰好是时间戳最擅长的。90 分钟是"一次专注审计"的时长上限,超过它,默认出事了,回收。为不存在的并发上分布式锁,是这类工具最容易犯的过度设计。
四、board.md 是缓存,不是事实源
看板文件 board.md 每次任何写操作之后全量重建:
typescript
export async function regenerateBoard(root: string): Promise<string> {
const markdown = renderBoard(await listCases(root));
await writeFile(join(huntbookRoot(root), BOARD_FILE), markdown);
return markdown;
}
规则很简单:cases/*.md 是唯一事实源,board.md 永远可以从事实源完整推导出来------所以它可以被删、可以被并发写坏、可以被手贱编辑,毫无关系,下次任何操作都会把它纠正回来。
这就是"幂等"在文件协议里的具体形态:把派生物和事实源物理分开,派生物允许脏。对比一下很多工具的做法------状态和视图存在同一个地方,坏一处就全坏。给 AI 会话用的系统尤其要这样:会话是有概率把文件改出花的,你要保证的是"改坏了也能自愈",而不是"指望它永远不改坏"。
五、故意不用 YAML
frontmatter 长得像 YAML,但解析器是我手写的,26 行:
typescript
/**
* Flat `key: value` frontmatter --- the only format huntbook needs. Deliberately
* NOT yaml: case files are machine-written, values are flat strings or null,
* and a hand-rolled parser keeps the zero-dependency promise.
*/
export function parseFrontmatter(markdown: string): {
fields: Record<string, string | null>;
body: string;
} {
const match = /^---\n([\S\s]*?)\n---\n?/.exec(markdown);
if (!match) return { fields: {}, body: markdown };
const fields: Record<string, string | null> = {};
for (const line of match[1].split("\n")) {
const colon = line.indexOf(":");
if (colon === -1) continue;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
fields[key] = raw === "" || raw === "null" ? null : raw;
}
return { fields, body: markdown.slice(match[0].length) };
}
不是不能引 yaml 依赖,是这个文件的根本属性被搞反了 :YAML 是"人写、机器读"的格式,宽容多义、语法 sugar 层出不穷;而 case 文件是机器写、人和机器都读 的------写入方是我自己的 serializeFrontmatter,值全是平面字符串或 null。机器写文件的格式,应该选机器最不会写错、写错了最容易修 的格式,而不是表达力最强的格式。平面 key: value 就是那个答案:正则一行截取,坏行跳过,整份文件没有一种写法能让解析器炸掉。
顺手引出的另一个原则:零依赖是安全属性,不只是洁癖。你的状态文件格式只被你自己的一段 26 行代码定义,就不会因为上游依赖的一个 breaking change 而变得读不出来。
六、输出是给"下一个命令"的,不是给人的
huntbook 的每个命令都遵守同一条输出纪律:结构化 markdown、无 TTY 依赖、连续两次运行结果幂等。
ruby
$ huntbook next
picked next case:
2026-08-28-acme-com-login-rate-limit-bypass --- Login rate limit bypass [high · A]
$ huntbook next
still on it (claimed 2026-08-28T09:12:00.000Z):
2026-08-28-acme-com-login-rate-limit-bypass --- Login rate limit bypass [high · A]
这套纪律是为 AI 会话定的:会话把上一条命令的 stdout 直接粘进下一段 prompt,所以输出必须机器可判读 ------picked 还是 still on it,一个前缀就能让会话分叉逻辑;exit code 认真给(0 干成了 / 1 没任务 / 2 用错了),CI 和会话都能判断成败。
还有个小设计藏在 CLI 里:--now 参数可以注入时间。
lua
huntbook next --now 2026-08-28T10:00:00Z
它本来是为了让 vitest 不用 mock 时钟(红测试从第一行开始写的,时间注入让"过期回收"这种时间相关的逻辑测试起来一目了然),但它同时给了 AI 会话一种有趣的能力:重放和推演------"如果两小时后我再领任务,队列会是什么样"。协议设计里凡是和时间有关的逻辑,留一个时钟注入口,测试和使用都会感谢你。
七、边界:什么时候这个模式会失效
诚实一点,"markdown 即数据库"不是银弹,三类需求出现时就该老实换方案:
- 真·高频并发写------多机、多进程每秒级抢写。文件 + 乐观窗口撑不住,需要真锁。
- 复杂查询------"列出所有 grade ≥ B 且三个月内没动过的 case"。目录扫描 + 内存过滤在几百个文件内毫无问题,上千个就该有索引了。
- 跨设备实时同步------git 是分钟级快照,不是实时通道。
huntbook 的赌注是:单人工作流终身都活在第一类的射程之外、第二类的几百个文件之内。目前它在我自己的真实产线上验证了几个月,状态机本身从更早的纯手工 md 流程提炼而来------先有实践,后有协议。
写在最后
回看这几个决定,其实共享同一个母题:AI 会话是最好的"哑终端" ------它不装你的 SDK,不连你的数据库,但它读文件、执行命令、认 stdout。为它设计系统,就是在为"一个只认识 cat 和 exit code 的同事"设计系统。而你会发现,能通过这种最苛刻客户端考验的接口,人类用起来反而格外舒服。
文件系统是被低估的 IPC。下次你的多会话工作流开始打架,先别急着起服务------看看是不是一个文件夹就能解决。
链接
- huntbook:GitHub ·
npx huntbook init即可开箱 - 文中源码:
src/pipeline.ts/src/frontmatter.ts/src/types.ts/src/cli.ts(全部源码 441 行,运行时依赖只有参数解析器)
我正在系统性地给 Vue 生态提 PR,同时维护两个小工具:distguard(构建产物凭证泄露扫描)和 huntbook。欢迎来 GitHub 上交流。