
文章同步發表在我的個人 Blog
30 天做完,留下來的是什麼?
昨天 Day 29 整理 AI 常寫錯的地方。今天是最後一天,回頭看四件事:投票最後的結果、這 30 天做出了什麼、最後的架構怎麼讓 AI 分工,以及哪些東西可以繼續用。
先講結果:投票頁最後是 19 個人、52 個圓點,最後一票在 10/1,前兩名從第一週到最後都沒變。Day 21 寫的 23 個人、64 個圓點多算了 4 筆測試資料,這裡更正。這個 App 的功能細節,我不在系列文章裡公開,所以對照的部分只寫到 Day 13 已經寫過的內容。架構上,領域規則全部放在不依賴 UI 和 Firebase 的 TeammateKit,Firebase 與 Google 登入只出現在 Teammate/Persistence/,這條邊界由 verify.sh 的掃描來檢查。也是因為這樣切,Claude Code、Codex 和我才分得了工。留下來可以繼續用的,是規則文件、一行就能跑完的驗證腳本,以及每一輪的記錄。
投票頁用同一個查詢,排除 9/19 凌晨的 4 筆測試資料之後,結果如下:
| 選項 | 選的人數 | 圓點數 |
|---|---|---|
| 做了好多事,另一半不知道,會覺得分工不均 | 12 | 15 |
| 今天吃什麼、要煮什麼,永遠是臨時才有人想 | 13 | 14 |
| 想休息,但沒先講,等到快爆炸才說 | 7 | 9 |
| 週末排了什麼課程,忘記紀錄、提醒,到當天才發現 | 7 | 8 |
| 出門前才發現東西沒帶,因為沒講好誰要準備 | 4 | 4 |
| 訪客補充:爸爸可以不要看手機嗎? | 2 | 2 |
| 訪客補充:爸爸廁所要待多久? | 0 | 0 |
人數是「有幾個人選了這一項」,每個人最多貼 3 個圓點,同一個選項可以貼不只一個,所以圓點數會比人數多。五個原本的選項加起來 50 個圓點,加上訪客補充的 2 個,剛好是 52。
前兩名從第一週就是同樣兩個選項。依選的人數排,「今天吃什麼」第一;依圓點數排,「做了好多事,另一半不知道」第一,兩種排法的前兩名一樣。
9/19 到 9/23 這五天有 15 個人、43 個圓點,之後 9/28 到 10/1 只多了 4 個人、9 個圓點,最後一票在 10/1。19 個人不算多,這份結果我當成方向的參考,不當成統計結論。
更正 Day 21:我寫的「23 個人、64 個圓點」是包含測試資料的總數,Day 13 已經排除的 4 筆測試也算進去了。扣掉之後是 19 個人、52 個圓點,Day 21 表格裡每個選項的數字也都多了測試的部分,以這一篇的表格為準。
Day 13 的結論是:前兩名都需要讓另一半知道,一個人用的 App 做不到。所以重做的版本有三個核心:每一件事記錄對方知道了沒有、兩支手機用 Firebase 同步、用本機通知提醒。這三件事是 Day 14 到 Day 25 的主線,從重做、Firebase 正式專案、Security Rules,到匿名帳號升級成 Google 登入。
這之外的功能,包含排名第一的「今天吃什麼」,以及其他幾個選項怎麼處理,我不在這裡寫。投票頁的用途是決定第一版的方向,這件事在 Day 13 已經用到了。
| Day | 做了什麼 |
|---|---|
| 1~6 | 收集痛點、做投票頁 |
| 7~12 | 方法與規範、安裝 Xcode、第一版 |
| 13 | 投票第一週結果,第一版重做 |
| 14~20 | 重做的版本、Firebase、裝到實機、Security Rules、把缺口寫成檢查 |
| 21 | 中場回顧 |
| 22~24 | 大字級版面、不用兩支手機測同步、對規則做變異測試 |
| 25~26 | Google 登入、簽章到期後重新安裝 |
| 27~28 | 英文語系、收集回饋與設定頁語言選項 |
| 29 | Vibe Coding 總檢討 |
程式分成五塊,箭頭是依賴的方向:

Day 25 加進來的 GoogleSignIn 套件,整個專案只有 FirebaseAccountLinker.swift 一個檔案 import 它,畫面層和 TeammateKit 都只認 AccountLinking。ADR-005 的做法是用 linkWithCredential 把 Google 帳號綁到既有的匿名帳號,uid 不變,所以已經建立的家庭和安全規則都不用動。如果 Google 登入不能用,退路是 Email 連結登入,只需要重寫 Persistence 裡的實作。
資料結構只有五個集合:
households/{hid} 家庭
households/{hid}/members/{uid} 成員(文件 id 是匿名 uid)
households/{hid}/cards/{cardId} 卡片
invites/{code} 邀請碼(文件 id 是 6 位數字)
userHouseholds/{uid} 這個帳號屬於哪個家庭
規則檔只開放這五條路徑,其他一律拒絕,新功能要先在規則加白名單。households、invites、userHouseholds 完全不能查詢,members 和 cards 只有該家庭的成員能查詢,所以 App 永遠是用已知的 id 直接取文件。createdAt 這類時間欄位必須是 FieldValue.serverTimestamp(),規則要求它等於 request.time,手機自己填的時間會被拒絕。
規則需要「現在」的地方,一律吃 Clock protocol 或 now 參數,不自己讀系統時間:
public protocol Clock: Sendable { var now: Date { get } }
// Rules never read the system clock
AcknowledgementRules.acknowledge(card, by: viewer, clock: clock)
NotificationPlanner.plan(events: events, cards: cards, viewer: viewer,
members: members, settings: settings, now: now)
測試用固定時鐘,要驗證「明天晚上八點」或「24 小時後過期」,不用等,也不用改系統時間。
「對方知道了沒有」也是一組純函式。卡片有 ackBy,nil 代表對方還沒知道。AcknowledgementRules.pending 只回傳「ackBy 是 nil,而且最後編輯的人不是自己」的卡片;最後編輯的人想確認自己的卡,會丟出 lastEditorCannotAcknowledge。編輯時,只要標題、時間或負責人變了,ackBy 就清回 nil,對方要重新知道一次,只改備註不會。App 圖示上的數字,也是同一個 pending 算出來的筆數。這些規則在 TeammateKit 裡用 swift test 幾秒內跑完,不需要模擬器,也不需要連 Firebase。
通知的重排只動真的有變的。沒有遠端推播(免費 Apple 帳號用不了,ADR-003),所以 App 內的「待你知道」是唯一保證的管道,通知是盡力而為的加分。資料同步之後,NotificationPlanner.plan 算出「現在應該排哪些本地通知」,diff 和已經排好的比,reschedule 只套用差異:
// Same id: overwrite in place. Cancel only ids that are really gone.
let replacedIDs = Set(changes.toSchedule.map(\.id))
let removedIDs = changes.toCancel.filter { !replacedIDs.contains($0) }
await scheduler.cancel(ids: removedIDs)
try await scheduler.schedule(changes.toSchedule)
內容或時間變了的通知,用同一個 id 直接覆蓋,不先 cancel 再 schedule。原因寫在程式註解裡:removePendingNotificationRequests 在背景非同步執行,可能在新增之後才刪,把剛排好的通知刪掉。
背景刷新是另一層。BGAppRefreshTask 只能「提出請求」,程式裡提的是最早 15 分鐘後,什麼時候真的執行由 iOS 決定,所以 ADR-003 把它列為可靠度最低的一層。
InMemoryBackend 是一個 actor,多個 InMemoryHouseholdService、InMemoryCardRepository 共用同一個 backend,就能在單元測試裡模擬兩支手機互相同步,不需要 Firebase。它實作的規則和 Firestore 安全規則一致:只有成員能讀寫、邀請碼 24 小時一次性、家庭最多兩人。每個 ViewModel 都能用這組 in-memory 實作建立,所以畫面邏輯的測試,也不用等 Emulator 啟動。
規則檔預設全部拒絕,子集合用白名單。判斷成員資格用 exists(),不用 existsAfter(),避免有人在同一批寫入裡先把自己加進成員、再順便讀寫。用邀請碼加入家庭時,「消耗邀請、建立自己的成員、把自己加進家庭」三份文件用 getAfter() 互相綁定,少任何一份整批失敗。邀請碼 24 小時有效,用伺服器的 request.time 計算,不信任手機時間。
規則檔裡也寫了已知的限制:6 位數的邀請碼可以被逐一猜測,規則沒辦法限制嘗試次數,所以用「24 小時、只能用一次、家庭滿兩人就拒絕」縮小影響範圍。這份規則有 99 個測試,在 Firebase Emulator 上跑。
./scripts/verify.sh 分五步:產生專案、TeammateKit 單元測試、Emulator 上的安全規則與 App 測試、掃描自我測試、靜態掃描。靜態掃描 scripts/scans.sh 有七項:
| 掃描 | 檢查什麼 |
|---|---|
| 禁用詞 | 不得有計分、排行、次數統計 |
| 硬編中文 | UI 文案都在 Localizable.xcstrings |
| 寫死樣式 | Features/ 不寫顏色、字級、間距數值 |
| 付費 capability | 免費帳號不能用的功能不得加入 |
元件 #Preview |
每個元件都有預覽 |
| Firebase 邊界 | import Firebase* 只能出現在 Teammate/Persistence/ |
| App 圖示 | 每個尺寸都有檔案,PNG 尺寸等於宣告的尺寸 |
最後一項的來源是實機:App 圖示只有 Contents.json、沒有圖片,建置不會報錯,到手機上才看到空白圖示,所以寫成檢查。
掃描本身也要被測試。scripts/scans-selftest.sh 用假檔案確認每一項真的抓得到,例如在 Teammate/Features/Home/HomeView.swift 放一行 import FirebaseFirestore,firebase-boundary 一定要失敗;在 TeammateKit 的 CardRepository.swift 放同一行,也一樣。沒有這一步,掃描壞掉變成永遠不命中,最後一行還是 VERIFY: PASS。
分工寫在 AGENTS.md 第 8 節,Claude Code 和 Codex 共用同一份:
| 角色 | 誰 | 做什麼 |
|---|---|---|
| 主 Builder | Claude Code | 依 BACKLOG.md 實作標 [cc] 的項目、寫測試、跑 verify.sh、整合 |
| 副 Builder | Codex | 標 [codex] 的項目:邊界清楚,只動 TeammateKit 的純函式或單一 Feature 資料夾,在獨立 worktree 的 codex/<項目編號> 分支上做 |
| Reviewer | Codex | 每個里程碑唯讀 review,結果寫進 reviews/ |
| Adversary | Codex | 同步、分享、通知這類高風險設計,用 adversarial review 挑戰假設 |
| 決定與動手 | 我 | 方向與取捨,例如 ADR-005 採用 Google 登入;AI 做不到的事,例如 Firebase Console、實機安裝、第二支手機 |
交叉規則是「誰寫的程式碼,就由另一方 review」。Codex 寫的項目合併前,由 Claude Code 對照驗收條件讀 diff;Claude Code 寫的,由 Codex 審。
這個分工要成立,靠的是前面幾節的架構。Codex 實作的 4 個項目都是不依賴畫面的純邏輯。TeammateKit 不依賴 UIKit、SwiftUI 和 Firebase,所以一個 worktree 只動一個資料夾,不會和 Claude Code 同時改同一個檔案。
兩個 Agent 也都不連雲端專案。所有測試連 Firebase Emulator,TeammateKit 的測試用 InMemoryBackend。ADR-002 在風險表裡寫了這個考量:Firebase 的 SPM 套件編譯慢,會拖慢 Agent 的迴圈,所以領域邏輯全放在 TeammateKit,swift test 維持在幾秒內。
兩邊用同一個判官,就是 ./scripts/verify.sh 最後一行的 VERIFY: PASS 或 VERIFY: FAIL。CLAUDE.md 規定每一輪結束前,要把這一行和 BACKLOG 的勾選狀態印在對話裡,因為評估的一方只看得到對話內容。
AGENTS.md 第 2 節有九條禁止事項,不靠 Agent 自己記得。第 1 條不計分、第 8 條不寫死樣式、第 9 條不加付費 capability,都有對應的掃描;Firebase 只能出現在 Persistence,是第 3、4 節的架構規則,也有掃描。其他像「不得刪除或弱化既有測試」,靠的是 review 和 PROGRESS.md 的記錄。
需要人動手的事,規則是寫進 docs/QUESTIONS.md 的「需要 Steven 動手」區,然後跳過,不自己假設。Day 29 整理的 26 條踩坑,來源就是這個流程裡 PROGRESS.md 的記錄。
最後一次 ./scripts/verify.sh(10/13)的結果是 VERIFY: PASS,各項數字如下:
| 項目 | 數字 |
|---|---|
| commit | 61 個(9/27 到 10/13,新專案) |
| TeammateKit 單元測試 | 59 個 |
| 安全規則測試 | 99 個 |
| App 測試(XCTest 與 XCUITest) | 223 個 |
| 掃描自我測試 | 50 個案例 |
| 架構決策紀錄(ADR) | 4 份 |
下一輪要開始,用到的是這幾樣:
AGENTS.md:Claude Code 和 Codex 共用的規則,包含禁止事項與每一輪的流程。./scripts/verify.sh:一行指令跑完單元測試、安全規則測試、App 測試和掃描,最後一行印出 VERIFY: PASS 或 VERIFY: FAIL。PROGRESS.md:每一輪做了什麼、踩到的坑、下一步。docs/decisions/:架構決策紀錄,說明為什麼沒有推播、為什麼用 Firebase。新的一輪從 PROGRESS.md 的「下一步」開始,照 Day 29 的七個步驟做,結束前印出 VERIFY: PASS。Day 28 的回饋流程也是這樣走的:收到回饋、分類、挑一個修、驗證、記錄。
如果你要開始自己的 Vibe Coding 專案,我建議第一天只做三件事:把驗收條件寫成有編號的條目、寫好一行就能跑完的驗證腳本、把 AI 不能做的事寫進規則文件。其他的,在做的過程中再補。
終於完成 30 天的鐵人賽了。
做完之後,我的感覺是 coding 的門檻大幅降低,能力的重心往創意、整合和規劃移動。但領域知識還是很重要,有了它,可以少繞很多冤枉路。
舉幾個這 30 天做下來有用的做法,給想用 Vibe Coding 做專案的人參考:
PROGRESS.md,下一輪從這份記錄開始。test_AC_TOPIC_03_replyByOverdueNotifiesBoth,之後才知道每一條條件有沒有被測到。VERIFY: PASS 之前,先看有沒有真的跑到測試。新增測試檔沒重跑 xcodegen generate,報告會是「執行 0 個、沒有錯誤」。PROGRESS.md 的卡點,改做下一項,不要一直在同一個地方打轉。系列文章