我开源了一个剪贴板工具,但代码只占一半 —— 另一半是我用 Claude Code 做 SDD 的全部痕迹
这不是又一篇 AI 编程吹牛文。这是一份带 commit 历史和踩坑笔记的项目档案,欢迎 fork、批评、复刻。
GitHub 仓库:github.com/smilezyl202…
一句话定位:跨设备剪贴板同步 + 5 个 sprint / 31 个真实任务的 spec-driven development 全过程公开。
0. 先说为什么写这篇
最近半年 AI Coding 类工具铺天盖地,但社区里能看到的"实战"大多停在两个极端:
-
极端一:演示型 demo。一句 prompt 生成一个 to-do app,截屏配 GIF,showcase 完事。
-
极端二:吹牛型复盘。"我用 Cursor 一周做了一个 SaaS",但 repo 不开源,过程不可见,方法论不可复用。
我想做点中间地带的事 —— 把一个能用的开源产品做出来,同时把"和 AI 怎么协作"的全部决策痕迹保留在仓库里,让任何人 clone 下来就能复盘。
跑了大概一个月,5 个 sprint、31 个任务、17 条结构化经验、44 个 e2e + 42 个单测,全部以纯 Markdown 持久化在 repo。这篇文章把这套工作流和我踩到的几个真实的坑分享出来。
1. 这个项目是什么
它有两个身份。
身份一:跨设备剪贴板同步工具
技术栈一目了然:
| 层 | 选型 |
|---|---|
| 框架 | Next.js 14(App Router) |
| 元数据 | Upstash Redis |
| 媒体存储 | Vercel Blob(客户端直传,绕开 4.5 MB Functions 上限) |
| 部署 | Vercel Fluid Compute |
| 测试 | Playwright(44 e2e)+ Vitest(42 单测) |
功能层面就是个普通 PWA:文本同步保留 7 天、图片/文件 3 小时自动清、单用户白名单(明文 x-user-phone 头 + ALLOWED_PHONE 环境变量)、深色模式、搜索筛选、撤销删除、Toast 队列、离线提示、拖拽上传……都是日常 SaaS 标配。
身份二:spec-driven development(SDD)+ harness engineering 的开放档案
这是我开源它的真正动机。
每一个功能立项前的需求文档(spec)、每一个任务完成后的 ≤200 字快照、每一次踩坑、每一次取舍、每一个外部集成的细节,全部以纯文件 + Markdown 形式持久化在 repo 里。activity 区始终薄,已完成 sprint 物理归档到 archive/,跨会话经验沉淀进 memory/。
repo 里能看到的关键路径:
.
├── .claude/rules/project-context.md # 长期硬约束
├── MEMORY.md # 经验滚动窗口(最近 17 条)
├── memory/
│ ├── .index.md # 全量索引(按 4 类分组)
│ ├── decisions/ # 4 条架构决策
│ ├── pitfalls/ # 5 个第三方坑
│ ├── conventions/ # 4 条隐性约定
│ └── integrations/ # 4 个外部系统集成
├── specs/archive/<sprint>/ # 5 个已归档 sprint
│ ├── <sprint>.md # WHY/WHAT/HOW
│ └── <sprint>.history.md # 任务完成快照
├── progress/archive/ # 任务进度归档
├── PROGRESS.md # 当前活跃工作区(31 行)
└── tests/manual/ # 手测清单
如果只想看产品,到此为止;如果想看后半部分,继续往下。
2. AI 协作做长周期工程的真问题
我先给一个反直觉的观察:
用 LLM 写一段代码很容易;用 LLM 持续 1 个月做一个长周期工程,却出乎意料地难。
难点不在生成代码本身,而在上下文管理。具体说有这么几个:
1. 上下文窗口是有限的,但项目记忆是无限的。 LLM 每个会话都是一个上下文窗口。但项目里的踩坑、决策、约定,是要跨会话累积的。比如我半个月前因为 Upstash Redis 在某些情况下会自动反序列化 JSON 而踩了坑,半个月后做新功能时如果忘了,又会写出同样的 bug。
2. AI 倾向于"看起来合理",但项目需要"持续可追溯"。 让 AI 实现一个功能,它会写得很顺。但一个月后回头看:"为什么这个 API 用 IP 限流而不是用户限流?""为什么这里用客户端直传而不是服务端代理?" —— 决策的理由如果不在仓库里,时间一长就丢了。
3. 自动化测试是宝贵的,但有些事自动化测不了。 e2e 能验证按钮点击逻辑,但验证不了"在 iOS Safari PWA 模式下双指缩放是否被正确阻止""深色模式下批量操作栏的对比度是否合 WCAG 标准""屏幕阅读器读出错误信息的语调自然不自然"。这些事必须有人来测,但人测之后的结论也得沉淀。
4. 完成的工作堆在仓库里,会让活跃区变得越来越难读。 项目第一周 PROGRESS.md 还能看,跑到第十周就 500 行起步。每次开会话都要翻一遍,注意力就被磨平了。
这四个问题加起来,就是为什么我们需要 SDD(spec-driven development)+ harness engineering 这种"框架",而不是仅仅靠"prompt 写得好"。
3. spec skill:一套围绕 LLM 的完整工作流
我用的不是裸 Claude Code,而是 Claude Code + 一个叫 spec 的 skill。这个 skill 定义了一套完整的 SDD 工作流。
它的核心是三源同步:
| 文件 | 内容 | 写入时机 |
|---|---|---|
PROGRESS.md | 任务状态([ ]/[🔄]/[⏸️]/[✅]),仅活跃 sprint | 每次任务推进 |
specs/<feature>.md | WHY/WHAT/HOW + 验收 + 任务清单 | spec 阶段 + 实施中可更新 |
specs/<feature>.history.md | 任务完成快照(每任务一段 ≤200 字) | Phase D 追加 |
MEMORY.md + memory/ | 跨任务经验积累 | 触发条件命中时 |
每个任务的执行路径强制按 Phase A/B/C/D 走:
-
Phase A — 读上下文:读 PROGRESS、读对应 spec、读 MEMORY 滚动窗口、读 spec §相关 memory 列出的全部文件、按关键词触发额外的 memory 加载
-
Phase B — 实现:按 spec 实施;新决策/坑/约定/集成细节立刻 draft 到
memory/<cat>/<topic>.md -
Phase C — 分层验证:C1 输出验证清单 → C2 自动化测试 → C3 手测清单(仅 🧑 项落地)→ C4 暂停等"测试通过"
-
Phase D — 三向同步:PROGRESS
[✅]+ spec[x]+ history.md 追加任务快照 + memory 定稿说白了,这套工作流强制把"上下文"具象化为文件,让 LLM 每次会话都从同一组文件读起,让人在每个关键节点(手测、测试通过)必须介入。
4. 实战:5 个 sprint 跑过的事
干跑空话没意思,看实际跑了什么:
| Sprint | 任务数 | 内容 |
|---|---|---|
| engineering-cleanup | 8 | 删 package-lock.json 统一 pnpm;page.tsx 拆分 AuthModal/RecordRow/RecordsSkeleton;hooks 提取 useRecords/useSwipe;统一 Record 类型;统一 API { error: string } 错误格式;清理死代码 |
| security-hardening | 5 | API 通用限流(60/min);登录专用限流(5/min);收紧上传 MIME 白名单;文本 10000 字符前后端双校验;Vercel Blob 缩略图 ?w=200 参数 |
| ux-enhancements | 9 | 拖拽上传;ESC 关 lightbox;删除撤销 5s 延迟 + Toast;离线横幅;Toast 队列 + 上限 5;上传失败保留占位 + 重试;跨域文件下载 fetch + Blob;focus-visible + aria-describedby;修复 SSR 水合布局闪烁 |
| search-filter-darkmode | 4 | 搜索框前端过滤;类型筛选标签(全部/文本/图片/文件);深色模式 CSS 变量 + 系统偏好检测;切换按钮 + localStorage 记忆 |
| testing | 5 | auth.ts 18 单测;store.ts 24 单测;E2E 文本同步流程;E2E 文件上传流程;E2E 登录失败 + 批量删除边界 |
每一行任务都对应仓库里一个 T-x.x.x ✅ 的快照(specs/archive/<sprint>/<sprint>.history.md)和一个 progress 归档(progress/archive/<sprint>.md)。每个 sprint 的 spec 头部还有"最后任务"行,链向 <sprint>.history.md 的对应锚点。
5. 4 个最值得分享的踩坑 / 决策
如果你只读这一节,请读这里。这是这个项目沉淀下来的几个真实有用的、不容易在文档上找到的细节。
5.1 Upstash Redis REST API 会自动反序列化 JSON
坑:你写 redis.set('key', JSON.stringify(obj)) 存进去,再 await redis.get('key') 读出来 —— 在某些 SDK 版本下,它已经帮你反序列化好了,返回的不是字符串而是对象。如果你假设它是字符串然后 JSON.parse(raw),就会抛 SyntaxError。
解法:防御性判断。
const raw = await redis.get(key);
if (typeof raw === 'string') {
return JSON.parse(raw) as UserData;
}
return raw as unknown as UserData;
沉淀位置:memory/pitfalls/upstash-deserialize.md
5.2 Vercel Functions 4.5MB 限制 → 客户端直传 Blob
约束:Vercel Functions 请求体 4.5MB 上限,服务端接收文件不可行。但产品需求是支持最大 50MB 的文件上传。
解法(三步流程) :
-
POST /api/records/upload—— 服务端签发 30 分钟有效期的上传 token,校验 MIME 和大小 -
@vercel/blob/client的upload()—— 客户端直传到 Blob 存储 -
POST /api/records/create-media—— 写 Redis 元数据陷阱:
onUploadCompleted回调在本地开发环境无法触发,所以客户端必须显式调用 create-media,不能依赖服务端回调。生产环境为了一致性也保持客户端主动通知。孤儿 Blob 兜底:第 2 步成功、第 3 步失败的情况下,blob 已上传但 Redis 无记录。Vercel Cron 每日 03:00 UTC 兜底清理。
沉淀位置:
memory/decisions/blob-direct-upload.md+memory/pitfalls/blob-callback-localhost.md+memory/integrations/vercel-cron.md
5.3 PWA 模式下 iOS Safari 无视 viewport meta
坑:把 PWA 加到 iPhone 主屏幕之后,iOS Safari 不再尊重 <meta name="viewport" content="user-scalable=no, maximum-scale=1">,用户依然可以双指缩放,类原生 App 体验立刻就破了。
解法:JS 层 preventDefault() 阻止:
-
gesturestart/gesturechange/gestureend(iOS Safari 特有事件) -
双指
touchmove(touches.length > 1) -
Ctrl+ 滚轮(桌面端缩放)代价:违反 WCAG 2.2.2(用户必须能缩放文本)。但 PWA 场景下没有更好的替代方案。
重要规则:项目里写了一条永久记忆 —— 不要"修复"或移除这些 JS 阻止代码。后续做无障碍优化的任何 sprint 都必须明确排除这部分。
5.4 Playwright route.continue() 会跳过后续 handler
坑:用 Playwright e2e 时常会同时挂多个 page.route() mock 不同 API。某次重构后两个 history-delete 测试失败,deletedId 始终是 null。
根因:
// ❌ 错误写法
await page.route('**/api/records', async route => {
if (route.request().method() !== 'GET') {
await route.continue() // 这一行直接发往真实服务端,跳过所有后续 handler
return
}
await route.fulfill({ ... })
})
route.continue() 在 Playwright 中是"绕过所有 mock 直接发到真服务端",不是传递给下一个 mock handler。要传给下一个 handler 应该用 route.fallback()。
// ✅ 正确写法
await page.route('**/api/records', async route => {
if (route.request().method() !== 'GET') {
await route.fallback() // 传递给下一个匹配的 handler
return
}
await route.fulfill({ ... })
})
沉淀位置:memory/pitfalls/playwright-route-fallback.md
6. 工作流跑下来的 6 条关键发现
跑完 5 个 sprint 后我把感受总结成 6 条,是写代码之外的事:
① 强制写 spec 比强制写测试更难,但收益更大。 spec 里写 WHY/WHAT/HOW + 验收标准是反直觉的("我都想清楚了为什么还要写下来"),但写一遍之后任务分解会自然落地,AI 实施时也不会偏题。
② Memory 系统不是文档,是一种"反熵"工具。 普通文档是给读者看的,memory 是给"未来的你 + LLM"看的。它的价值不在内容多丰富,而在索引格式严格统一(每行 ≤80 字符 + 标准 stem 格式),让 grep / 关键词触发能稳定命中。
③ 自动化与手测必须明确区分,不能混淆。 spec skill 给了一份"手测硬性条件清单":视觉/动画体感、跨真机、第三方真服务、OS 级用户操作、内容质量主观、可访问性主观、长耗时敏感账号、真 GUI 应用 —— 不命中任一条的,默认归 🤖 自动化。这条规则消除了"这个我不会写自动化所以归手测"的偷懒空间。
④ 测试驱动 ≠ 测试反向写。 反模式之一是"读实现再写测试" —— 读完 useXxx.ts 的代码再写"测试 X 显示为 Y",结果测试只是代码复读机,0 bug 发现能力。正确做法是闭眼想"用户拿到功能怎么用 / 边界怎么触发 / 错误怎么恢复",再对照实现。
⑤ 物理归档比 git 分支更适合知识管理。 git 分支适合代码版本控制,但不适合"已完成 sprint 怎么从活跃工作区淡出"。物理归档(mv specs/<feature>.md → specs/archive/<feature>/)是纯文件系统操作,不依赖 git,活跃工作区始终薄,归档区按需懒读。
⑥ "测试通过"是人工授权的暂停点,不能让 AI 自己宣布成功。 Phase C4 强制要求"用户回复'测试通过'我才进 Phase D"。这条小规则避免了 AI 在测试失败时"乐观地修测试让它过"的反模式,把质量把关权留给人。
7. 这套工作流怎么复用到你自己的项目
如果这套思路对你有用,你可以这样复刻:
Step 1:在你的项目根目录创建以下结构(其实就是一些空文件夹和模板):
.claude/rules/project-context.md # 长期约束模板
specs/ # 活跃 spec
specs/archive/ # 归档
progress/archive/ # 任务进度归档
memory/decisions/
memory/pitfalls/
memory/conventions/
memory/integrations/
memory/.index.md # 全量索引模板
MEMORY.md # 滚动窗口模板(FIFO 20 条)
PROGRESS.md # 任务状态模板
tests/manual/ # 手测清单
Step 2:每个新功能从 spec 起步:
"/spec 我想新增 XX 功能,需求是 YY"
让 AI 进入 Interview 阶段,问你 6 个维度的问题(目标动机 / 边界 / 技术约束 / 边界条件 / 验收标准 / 优先级与依赖),输出 spec 草案给你审。
Step 3:审完之后任务分解、/clear 进新会话开始第一个任务。每个任务严格走 Phase A/B/C/D。
Step 4:sprint 跑完之后物理归档,活跃工作区清零。
仓库里有完整的模板和示例,你可以直接 fork 走、把 specs/、memory/、PROGRESS.md 清空、从你自己的 /spec init 重新开始。
8. 写在最后
我做这个项目的初衷不是"卖 AI Coding 工具",而是想搞清楚一件事:
LLM 当下能不能在长周期工程里持续产生价值?答案是能 —— 但要有 harness。
裸用 LLM 做 1 周以上的工程,上下文丢失、决策失忆、活跃区膨胀这些问题会逐步把质量拖下来。spec skill 这套 harness 的本质是用纯文件 + 严格格式约束给 LLM 建一个外置长期记忆,把"上下文"从隐式变成显式。这件事 cursor、Copilot、Claude Code 谁都能做,工具不重要,方法论才重要。
如果你:
-
想用一个能跑的剪贴板工具 → fork 一键部署到 Vercel 即可,详细文档见 README
-
想看 SDD 实战的真实痕迹 → 直接读
specs/archive/和memory/ -
想用这套工作流做你自己的项目 → 仓库里所有模板都能直接复用
-
对 SDD/harness engineering 本身有想法 → 欢迎到 issue 讨论
这是个长期的开放实验。欢迎参与,欢迎批评,欢迎复刻。