乐于分享
好东西不私藏

mdtask 源码精读:2576 行 2 个依赖,spec 不漂移

mdtask 源码精读:2576 行 2 个依赖,spec 不漂移

导读

一个任务管理工具,把「完成任务」的命令删了。mdtask 全部实现只有 2576 行 TypeScript、2 个运行时依赖:没有数据库,没有服务器,spec 和任务住进同一个 Markdown 文件,关任务的编辑和代码进同一个 commit

一个任务管理工具,把「完成任务」的命令给删了。

我在 mdtask 的 git 历史里翻到这条 commit 的时候,停下来看了两遍:FEAT: Remove the mdtask done command (CLI-067),一口气删掉 234 行:41 行实现,178 行测试,外加文档里的所有引用。commit message 只有一句解释:

The toggle command is unnecessary — agents and users edit markdown files directly.

翻译过来:打勾这个动作不配拥有一个命令,人和 agent 直接改 Markdown 文件就行。

绝大多数任务工具的思路正好相反:任务是数据库里的记录,状态迁移必须走 API,done 是最核心的那个 transition。mdtask 反着来——文件才是本体,CLI 只是个解释器。这个思路贯穿了整个项目,也是我把它的 2576 行源码全部读完之后,决定写这篇的原因。

先介绍下主角。mdtask 是一个开源的 spec 驱动开发工具:spec 和任务写在同一个 Markdown 文件里,跟代码住同一个 repo,人和 AI agent 共用同一套任务管理系统。仓库地址:https://github.com/syabro/mdtask,2026 年 1 月底创建,现在 v0.1.17,59 个 star。

体量小得离谱。整个 src/ 六个文件,2576 行 TypeScript,运行时依赖两个:cac(命令行参数解析)和 picocolors(终端着色)。没有数据库,没有服务器,没有索引文件,没有 daemon。测试倒是很舍得写,19 个 vitest 文件共 6000 行,是实现的两倍多。

star 不多,但这个项目把一个问题想得特别透:AI 时代,spec、任务、代码这三样东西,怎么才能不各说各话。

01

THE PROBLEM

三地分居的病

这个痛点估计你也有。spec 写在某个 docs 目录里,任务躺在 Jira 里,代码在 git 里。三个系统,靠人肉同步。spec 永远是上个版本的样子,任务状态永远慢半拍。

人写代码的时候,这事只是烦。agent 写代码的时候,这事是事故:agent 把过期的 spec 当真相,照着盖楼,盖完才发现地基早改了。

mdtask 的解法一句话:把 spec 和任务放进同一个 Markdown 文件,跟代码住同一个 repo,关任务的那次编辑和代码改动进同一个 commit。文件是唯一的真相源,git 是唯一的同步机制。

具体长这样。一个 spec 文件,上半部分是散文,写的是「系统现在是什么样」(手册);下半部分一个 # Tasks 标题,下面是 checkbox 任务(待办加历史):

...markdown

# Brewing — Smart Kettle

Wi-Fi kettle with app control.

# Tasks

- [ ] KTL-001 Basic boiling with auto shut-off

 Heat to 100°C, beep on completion.

任务行后面可以挂三种元数据:#tag!high(优先级)、@key:value(属性)。所有属性里只有一个有内置行为:@blocked_by:KTL-001。被阻塞的任务默认从 list 里隐藏,阻塞解除了才冒出来。其余的 @status:doing 之类,工具一概不管语义,纯项目内部约定。

02

DATA MODEL

划重点

Markdown 是张表

docs/mdtask.md 里有段话,我认为是整个项目的「独立宣言」:

Markdown is not presentation — it is a structured table where: line = record, indent = block boundary, inline tokens = columns. CLI is only an interpreter, never the owner of data.

Markdown 不是给人看的排版,是一张结构化的表:一行是一条记录,缩进是块的边界,行内 token 是列。CLI 只是解释器,永远不拥有数据。

这句话解释了后面所有的设计决策。因为 CLI 不拥有数据,所以不需要 done 命令;因为行就是记录,所以解析器不需要 Markdown AST,逐行扫正则就够;因为文件是真相,所以所有写操作都按「直接编辑文本」的纪律来做。

03

PARSER

解析器:一个反直觉的扫描方向

读解析器之前,我以为元数据是从左往右切的:碰到第一个 # 或 ! 就开始算。mdtask 反着来,从右往左扫(task.ts:66-76):

...ts

// Metadata is the trailing run of metadata tokens at the end of the line.

// Scan tokens right-to-left; stop at the first non-metadata token.

let boundary = rest.length;

for (const m of [...rest.matchAll(/\S+/g)].reverse()) {

 if (!isMetadataToken(m[0])) break;

 boundary = m.index;

}

title = rest.slice(0, boundary).trimEnd();

rawMetadata = rest.slice(boundary).trimEnd();

为什么必须从右边扫?两个例子就懂了:

Fix #123 in parser

#123 是 GitHub issue 号,属于标题

Refactor parser !high #cleanup

尾巴上这两个词才是元数据

元数据的定义是「行尾连续的元数据 token 串」。从右往左,碰到第一个不像元数据的词就停。从左往右扫没法区分上面两种情况,从右往左天然正确。配套还有个细节:tag 正则要求字母开头(/^#[A-Za-z][\w-]*$/),所以纯数字的 #123 永远不可能被当成 tag。双保险。

另外留了个逃生门:万一标题真的以元数据词结尾,用两个 tab(\t\t)显式分隔标题和元数据,解析器优先认这个。

04

FENCE MASK

代码块里的 checkbox 不是任务

第二个精巧处是 fence mask。spec 文件里经常要放示例,比如文档里演示任务格式长什么样,这些示例 checkbox 写在代码块里。不处理的话,mdtask list 会把文档示例当真任务列出来,mdtask ids 甚至会给示例发 ID。

mdtask 的解法是手搓了一个 CommonMark 围栏状态机(task.ts:99-134):先把整个文件扫一遍,给每一行打上「是否在代码块内」的标记,之后所有命令扫描时跳过这些行。

这个状态机认真得有点可爱:开围栏要 3 个以上反引号或波浪号、缩进 0 到 3 个空格(tab 不算数);反引号开的围栏,info string 里不能再含反引号;闭围栏必须字符相同、长度不小于开围栏、后面只能跟空白;没闭合的围栏一直延伸到文件结尾。全是 CommonMark 规范里的边角规则,一条没落。

这个功夫没白花。你没法要求用户「别在 spec 里写示例」,只能让工具聪明一点。

同类纪律还有一处:任务 body(header 下面缩进的行)的边界,全项目只有一个函数说了算:findTaskBlockRange。view 用它决定打印多少,move 用它决定剪切多少,archive 用它决定搬走多少。「任务块是什么」这个定义,不可能在三处各自漂移。

05

ID SYSTEM

ID 系统:数字全局唯一

任务 ID 是 PREFIX-NNN 格式,比如 CLI-087。有意思的是这条规则:NNN 这个数字跨前缀全局唯一CLI-087 和 PRJ-087 不该同时存在,validate 会对撞号发警告,纯数字查找遇到撞号直接报歧义错误。

代价是发号麻烦一点(新号码 = 全库最大号码 + 1),收益是 mdtask view 87 不用敲前缀。对人来说省几个按键,对 agent 来说是一个稳定无歧义的寻址方式。对话里引用任务,一个数字就够。

发号命令 mdtask ids 的实现是两阶段的:第一遍扫所有文件、确定每个文件用什么前缀,任何一个文件定不下来,直接报错退出,一个字符都不写;第二遍才发号落盘。前缀的推断链有四级:文件里已有任务的多数派前缀 → 种子行(- [ ] CLI- 标题,有前缀没号码)→ --prefix 参数 → TTY 下开口问你。非 TTY 环境(比如 agent 在跑)问不了人,就报错并附上三种解法。

06

WRITE SAFETY

划重点

一个 CLI 的「事务」自觉

写操作是这类工具最容易翻车的地方。你在终端跑 mdtask move,编辑器里同时还开着这个文件,怎么办?

mdtask 的答案是三件套。第一,先写目标、后删源(cli.ts:424):

...ts

// Write target first (safer: avoids data loss if target write fails)

targetContent += `${blockLines.join('\n')}\n`;

writeFileSync(resolvedTarget, targetContent);

// Remove block from source

lines.splice(headerIndex, blockEnd - headerIndex);

writeFileSync(task.filePath, lines.join('\n'));

目标文件写成功了才动源文件。顺序反过来,目标写失败那一刻数据就丢了。archive 同理,归档文件先落盘。

第二,同文件多个任务一起归档时,按行号从大到小倒序删(cli.ts:603):

...ts

for (const block of [...fileBlocks].sort(

 (a, b) => b.headerIndex - a.headerIndex,

)) {

 lines.splice(block.headerIndex, block.blockEnd - block.headerIndex);

}

从文件尾部往前删,前面的行号才不会在删除过程中失效。文本处理的老把戏,但忘了它的工具我见过不止一个。

第三,乐观并发检查。写之前重新读文件,确认任务还在它该在的那一行:

...ts

if (!line.includes(task.id)) {

 process.stderr.write(

  `mdtask: file changed, task '${id}' not at expected line\n`,

 );

 process.exit(1);

}

发现文件被动过就直接退出,不猜、不强写。三件套本质上是在承认一件事:CLI 不拥有数据,用户和 agent 随时可能在改文件,工具得对自己的假设保持怀疑。

07

THREE LAYERS

三层架构:格式给 CLI,方法给 skill,循环归 agent

到这里还只是个「好用的 CLI」的故事。mdtask 真正值钱的是下面这层分工。README 写得很直白,mdtask 是三层:

CLI 管格式

读写 Markdown 任务,对方法论一无所知

skills 管方法

spec 怎么写、任务怎么关,全在 4 个纯文本 SKILL.md 里(sdd / mdtask / mdtask-add / mdtask-do,加起来 635 行 Markdown)

循环管推进

一个接一个把任务做完,这是你的 agent 的事,mdtask 自己不提供任何循环和编排

第二层是精髓。方法论没有编译进代码,而是写成了 agent 能直接读的 Markdown。比如 mdtask-do 这个 skill,定义了一个任务从挑到提交的标准动作:挑任务 → 写计划 → 把计划发给另一个模型评审 → 实现加行为验证 → 代码评审 → 更新 spec → commit。遇到只有人能拍板的问题,不许猜:给任务打上 #user-required 标签、在任务体里写清楚悬而未决的问题,泊车,下一个。

关任务的动作也定义在 skill 里而不是 CLI 里:打勾、任务体里补一段 **Implemented:**、更新上半部分的手册,三件事和代码改动进同一个 commit。现在回看删 done 命令的那个决定,逻辑圆上了:状态迁移是 API 思维,编辑是文件思维,mdtask 选了文件。

这些 SKILL.md 不锁 Claude Code,任何能加载 SKILL.md 的 agent harness 都能用。mdtask install-skills 把它们软链进 agent 的技能目录,实现里还带版本缓存和「只碰自己创建的链接」的洁癖,不会覆盖你自己建的同名目录。

08

DOGFOODING

自己吃自己的狗粮

mdtask 项目本身就是用 mdtask 开发的,证据链完整:

spec 即仓库

docs/specs/ 下六份 spec,_archive.md 里躺着 57 个已完成任务

commit 带任务 ID

commit message 强制带任务 ID,AGENTS.md 里明文规定

CLI 优先铁律

能用 CLI 就绝不手读任务文件(Never parse task files manually)

AI 联合作者

最近 50 个 commit 里,8 个挂着 Co-Authored-By: Claude Opus 4.8 (1M context),这套流程是真在跑

我最喜欢的一个细节是一条挪任务的 commit:Move CLI-065 to PRJ-081: task-journal boundary is an sdd convention, not CLI。意思是:有人(大概率是 agent)把「spec 文件里 # Tasks 这个边界」当成了 CLI 的约束来实现,review 时发现划错层了,这个边界是 sdd 工作流的约定,CLI 根本不该知道。于是挪任务、改归属,整个过程留在 git 历史里。分层不是 PPT 上的图,是犯了错要挪回去的纪律。

09

HONEST LIMITS

诚实的边界

最后说个少见的。README 里有一节叫 「What mdtask doesn’t do」,自己拆自己的台:

不保证 spec 不漂移

同 commit 更新 spec 是工作流的约定,不是工具的保证。你完全可以打个勾、留下过期的手册,CLI 不检查。skill 把更新变成流程里的一步,diff 让偷懒可见,仅此而已。

不会自己跑到终点

需要人来拍板的任务,泊车等你,不猜。

不管实时协作

没有看板没有 dashboard,多人并行编辑就是普通的 git 冲突,手工解。

把自我设限写在明处,反而让人信它做得到承诺的部分。

10

TAKEAWAY

带走的三个判断标准

如果你只是想要个本地 TODO 工具,mdtask 对你是过度设计,编辑器插件就够了;如果你在让 agent 长时间跑任务、被「agent 拿过期 spec 当真相」坑过,这套设计值得抄。不一定要用这个工具,但这三个判断标准可以直接拿去衡量任何同类工具:

数据是不是纯文件?

有数据库、有索引、有 daemon 的,长期一定会和文件打架

方法论在哪层?

编译进代码的方法论,换个团队换个习惯就得换工具;写成 skill 的,改 Markdown 就行

循环归谁管?

工具自己带编排的,早晚和你的 agent harness 抢方向盘

59 个 star 的项目,把这些事想得比一堆几千 star 的同类都清楚。源码不长,一晚上能读完,推荐你也读读。

你现在的项目里,spec 和任务是怎么管的?评论区聊聊。觉得有收获的话点个关注,下篇继续拆有意思的源码。

引用来源

GitHub 仓库:https://github.com/syabro/mdtask(v0.1.17,PolyForm Shield 1.0.0)

mdtask README「What mdtask doesn’t do」、docs/mdtask.md、docs/positioning.md

commit 64085d1:FEAT: Remove the mdtask done command(CLI-067,2026-06-13)

skills/sdd、skills/mdtask-do 等 SKILL.md 原文

END
我是 Spero AI,热衷于分享 AI 观察与干货。

如果你觉得今天这篇有收获,欢迎点赞、在看、转发三连,我们下篇见。