我开源了一个剪贴板工具,但代码只占一半

4 阅读12分钟

我开源了一个剪贴板工具,但代码只占一半 —— 另一半是我用 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>.mdWHY/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-cleanup8删 package-lock.json 统一 pnpm;page.tsx 拆分 AuthModal/RecordRow/RecordsSkeleton;hooks 提取 useRecords/useSwipe;统一 Record 类型;统一 API { error: string } 错误格式;清理死代码
security-hardening5API 通用限流(60/min);登录专用限流(5/min);收紧上传 MIME 白名单;文本 10000 字符前后端双校验;Vercel Blob 缩略图 ?w=200 参数
ux-enhancements9拖拽上传;ESC 关 lightbox;删除撤销 5s 延迟 + Toast;离线横幅;Toast 队列 + 上限 5;上传失败保留占位 + 重试;跨域文件下载 fetch + Blob;focus-visible + aria-describedby;修复 SSR 水合布局闪烁
search-filter-darkmode4搜索框前端过滤;类型筛选标签(全部/文本/图片/文件);深色模式 CSS 变量 + 系统偏好检测;切换按钮 + localStorage 记忆
testing5auth.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 的文件上传。

解法(三步流程)

  1. POST /api/records/upload —— 服务端签发 30 分钟有效期的上传 token,校验 MIME 和大小

  2. @vercel/blob/clientupload() —— 客户端直传到 Blob 存储

  3. 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 特有事件)

  • 双指 touchmovetouches.length > 1

  • Ctrl + 滚轮(桌面端缩放)

    代价:违反 WCAG 2.2.2(用户必须能缩放文本)。但 PWA 场景下没有更好的替代方案。

    重要规则:项目里写了一条永久记忆 —— 不要"修复"或移除这些 JS 阻止代码。后续做无障碍优化的任何 sprint 都必须明确排除这部分。

    沉淀位置memory/decisions/pwa-js-zoom-prevention.md

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 谁都能做,工具不重要,方法论才重要

仓库地址:github.com/smilezyl202…

如果你:

  • 想用一个能跑的剪贴板工具 → fork 一键部署到 Vercel 即可,详细文档见 README

  • 想看 SDD 实战的真实痕迹 → 直接读 specs/archive/memory/

  • 想用这套工作流做你自己的项目 → 仓库里所有模板都能直接复用

  • 对 SDD/harness engineering 本身有想法 → 欢迎到 issue 讨论

    这是个长期的开放实验。欢迎参与,欢迎批评,欢迎复刻。