
文章同步發表在我的個人 Blog
讓 AI 自己跑一整天,你怎麼知道它做出來的東西是對的?
昨天決定第一版重做,今天講重做的版本是怎麼做出來的。
先講結果:從 9/27 凌晨 3 點到 9/28 凌晨 1 點,新專案累積了 51 個 commit。第一個里程碑(專案骨架、設計系統)9 項做完 8 項,第二個里程碑(邀請加入、首頁、行事曆、通知、雙人同步)14 項做完 13 項,剩下的兩項都要拿實機才能做。

做法有三個:規則寫成文件,Claude Code 和 Codex 照同一份做;AI 說做完不算,檢查腳本通過才算;Claude Code 寫的交給 Codex 審,Codex 寫的交給 Claude Code 審。審查抓到的問題裡,有一個跟 Swift 的取消機制有關:叫一個工作停下來,它不一定會停,第 5 節用程式碼說明。
Day 8 介紹過 AGENTS.md:Claude Code 和 Codex 讀同一份規則,CLAUDE.md 只匯入它再補幾行。重做的版本沿用這個做法。為了趕進度,前兩個里程碑的規格直接寫在文件裡;之後的新功能,會照 Day 7 的流程用 Spectra 開規格。
除了 AGENTS.md,這次多了四份文件:
| 檔案 | 用途 | 誰能改 |
|---|---|---|
docs/acceptance.md |
驗收條件,每一條都有編號 | 只有我 |
docs/DESIGN.md |
顏色、字級、間距、元件、畫面構圖 | 只有我 |
BACKLOG.md |
任務清單,每一項標好由誰做 | AI 打勾 |
docs/QUESTIONS.md |
AI 有疑問、需要我決定或動手的事 | 雙方 |
DESIGN.md 規定的顏色和元件,做成一頁只有開發版才看得到的元件展示頁。第一個里程碑的設計審查,Codex 看的就是這一頁的截圖:

AGENTS.md 的禁止事項寫明「違反任一條,這一項就不算完成」,例如:不能出現任何計分或次數統計、不能改需求文件、不能刪除或弱化測試、不能加入需要付費開發者帳號的功能。
驗收條件有編號,測試名稱就能對回去。例如 test_AC_SYNC_02_cardAndAcknowledgementSyncWithinFiveSeconds,看名字就知道它驗的是 AC-SYNC-02:一支手機新增卡片、按「知道了」,另一支 5 秒內都要看到。
每一項都要跑同一個腳本 ./scripts/verify.sh,最後一行印出 VERIFY: PASS 才算完成。腳本的主要內容:
xcodegen generate # 1. regenerate the Xcode project
swift test --package-path Packages/TeammateKit # 2. pure Swift logic, seconds
firebase emulators:exec --only auth,firestore \
"./scripts/_integration.sh" # 3. rules tests + xcodebuild test
./scripts/scans-selftest.sh # 4. prove the scans still catch violations
./scripts/scans.sh . # 5. static scans
echo "VERIFY: PASS"
任何一步失敗,就印出 VERIFY: FAIL 加上原因,後面的步驟不跑。
project.yml 產生,AI 不能直接改專案檔grep 找違反規則的寫法,例如計分相關的字、畫面上寫死的顏色、需要付費帳號的功能。第 4 步先檢查掃描本身:放違規和合法的假檔案(共 46 個案例),違規的一定要判為失敗、合法的一定要通過。不這樣做的話,掃描規則寫錯、永遠抓不到東西,也會被當成通過AGENTS.md 寫了分工,BACKLOG.md 每一項標 [cc](Claude Code)或 [codex]。這兩天實際的分工:
| 角色 | 誰 | 這兩天實際做了什麼 |
|---|---|---|
| 主 Builder | Claude Code | 專案骨架、Firestore 資料層、雙人同步測試、通知、把各功能接起來 |
| 平行開發 | Claude Code 的 subagent | 設計元件庫、首頁、首次啟動、行事曆、權限規則,各自在獨立的 git worktree 裡做,完成後由主 Builder 合併 |
| 副 Builder | Codex | 4 個純邏輯項目:資料模型、「對方已知」的規則、每週重複行程的展開、通知排程 |
| Reviewer | Codex | 每個里程碑結束時唯讀審查程式和畫面截圖 |
| Adversary | Codex | 挑戰高風險的設計:同步、權限規則、背景通知 |
Codex 的項目刻意只放不依賴畫面的純 Swift 邏輯。它在自己的 worktree 裡只要 swift test 幾秒鐘就能驗證,不需要模擬器;Claude Code 讀它的 diff,對照驗收條件,確認測試真的在驗證行為,才合併。
交叉的規則是:誰寫的程式,就由另一方審。
Codex 的審查結果分三級:P0 必須修、P1 應該修、P2 之後再說。兩個里程碑下來,Codex 做了 7 份審查,沒有 P0,但有不少 P1。下面是 Codex 以 Adversary 角色(專挑設計假設的漏洞)審第二個里程碑的結果,列了 8 條 P1:

挑其中三個:
背景刷新設了 25 秒期限,但不一定守得住。 細節在第 5 節。
通知重排可能讓已刪除的提醒復活。 每次資料同步完成,App 就重新排一次本地通知。如果上一輪還停在 await 等系統回應,新的一輪已經先做完,上一輪回來後用舊資料寫入,已刪除行程的提醒就又排回去了。修法是同一時間只跑一輪;中途進來的變更只記住最新的,這一輪做完再照最新的排一次。
首頁沒有顯示每週重複的行程。 行事曆會把每週六的游泳課逐週列出來,首頁卻只看行程第一次的日期;游泳課如果從上週開始,這週的首頁就看不到。
反過來,Claude Code 也抓到 Codex 的問題。Codex 寫的 4 個項目合併前都沒有退回,但寫 Day 13 時對照文章,發現 Codex 寫的通知排程遇到內容有變更的通知,是先移除、再用同一個 identifier 加回去。這就是 Day 13 第 6 節寫的那件事:iOS 移除待送通知是非同步執行的,可能等新的加好之後才移除,把剛加的那則也刪掉。Claude Code 改成用同一個 identifier 直接覆蓋,再交給 Codex 審。
Claude Code 也會抓自己 subagent 的問題。寫權限規則的 subagent 訂了一條「更新卡片時,最後修改者必須是自己」。對照「對方已知」的規則時發現:對方按「知道了」只寫入已知的時間,不算修改內容,最後修改者還是原本的人,所以會被這條規則擋掉。於是請它補了一條「只確認」的更新路徑,並加上測試。
背景刷新是 iOS 在背景把 App 叫醒一下,讓它拉新資料。能跑多久由系統決定,超過就會被停掉,所以要自己設期限。
背景刷新時同時做兩件事:向伺服器拉最新的卡片,以及倒數 25 秒。哪一件先完成就用哪一件的結果;倒數先到,就要取消還沒回來的網路請求。一開始的寫法是用 task group(同時跑幾個子工作,等它們都結束)讓兩件事賽跑:
await withTaskGroup(of: [Card]?.self) { group in
group.addTask { try? await fetch() } // pull latest cards
group.addTask { try? await Task.sleep(for: timeout); return nil }
let first = await group.next() ?? nil // whichever finishes first
group.cancelAll() // only a request
return first // group still waits for fetch
}
看起來時間到就會返回,問題是 cancelAll() 只是告訴網路請求「該停了」,不會強制停掉。要不要停、什麼時候停,由那個工作自己檢查、自己決定。官方文件是這樣寫的:
Swift concurrency uses a cooperative cancellation model. Each task checks whether it has been canceled at the appropriate points in its execution, and responds to cancellation appropriately.
就像跟正在講電話的同伴說「該走了」,他什麼時候掛電話由他決定,你只能等。task group 也一樣,要等所有子工作結束才會返回;網路請求如果卡住、一直沒去檢查取消,整個背景刷新就跟著卡住,超過 iOS 給的時間。
修法是不等它。withCheckedContinuation 讓我們自己決定這個 await 什麼時候結束:網路請求和倒數各跑一個 Task,先到的一方呼叫 resume 交出結果。時間先到就直接返回空結果;網路請求一樣會收到取消,但不必等它結束。
await withCheckedContinuation { continuation in
let work = Task {
let value = await operation()
if gate.claim() { continuation.resume(returning: value) } // first one wins
}
Task {
try? await Task.sleep(until: deadline, clock: .continuous)
if gate.claim() {
work.cancel() // still ask it to stop
continuation.resume(returning: nil) // return now
}
}
}
continuation 只能 resume 一次,重複 resume 會讓 App 直接當掉。gate 是一個加鎖的旗標,兩個 Task 都要先搶到它才能 resume,所以只會有一方成功。晚到的結果回來時旗標已經被拿走,結果直接丟掉,不會拿去排通知或改徽章。
測試也照這個情況寫:假的資料來源故意不理會取消,1.5 秒後才回來;期限設 200 毫秒,要求 0.8 秒內返回,而且等晚到的結果回來之後,確認沒有發通知、沒有改徽章。
codex exec 在背景執行時會一直等標準輸入結束,沒有任何輸出。在指令後面加上 </dev/null(給它空的輸入)就好了。AI 遇到需求寫得不清楚、或需要取捨的地方,不自己決定,寫進 QUESTIONS.md:
| 編號 | 問題 | 目前 |
|---|---|---|
| A-001 | 燈號的 Dark 顏色只寫「提亮 10%」,沒有色碼 | 先照 AI 的解讀做,等我給色碼 |
| A-002 | 6 位數邀請碼可能被猜中,要接受風險,還是花錢用雲端函式擋 | 接受風險:24 小時失效、只能用一次、滿兩人就不能再加入 |
| A-003 | 主按鈕白字的對比不到無障礙標準,改深色字可以嗎 | 維持深色字 |
| A-004 | 目前用匿名登入:把 App 刪掉再重裝會變成新的身分,家庭已經滿兩人就加不回去 | 先維持匿名,之後改成 Google 登入 |
| A-005 | 設計審查的 4 條 P1 都只影響最大字級,例如浮動按鈕會蓋住內容 | 先延後修 |
這幾題都是取捨,不是寫程式就能回答,所以留給我決定。
| 項目 | 結果 |
|---|---|
| commit | 51 個 |
| 第一個里程碑 | 9 項做完 8 項,剩 1 項要實機測試 |
| 第二個里程碑 | 14 項做完 13 項,剩裝到兩支手機 |
| 驗收 | VERIFY: PASS:共用邏輯 48、權限規則 79、App 116 個測試 |
| Codex 審查 | 7 份,沒有 P0;設計的 4 條 P1 延後 |
還沒做的部分:
Day 15 建立 Firebase 專案、兩支 iPhone 開啟開發者模式、裝到手機使用。
官方文件