96 KiB
M0+M1 实施裁定记录(ledger)
本文件是
2026-09-10-ring-health-app-m0-m1.md那份实施计划的执行期裁定记录, 由 subagent-driven-development 流程的 ledger 原件(progress.md)原样归档而来。为什么要归档而不是丢弃: 交付的代码与文档里有 14 个文件(含
Formatters.kt、MetricDetailScreen.kt、RingHealthApp.kt、多份单测、设计文档与验收记录)以 「ledger Ruling R<N>」的形式引用这里的条目。流程本来的收尾动作是删掉这份 scratch, 但那样会让上面每一处引用指向一个不存在的记录 —— 所以先归档、再删 scratch。每条裁定的格式是
Ruling: <决定了什么> — <为什么> — <错了要付什么代价>。 编号 R13 起为计划执行期的裁定;R13 之前的几条(不建 worktree、.superpowers/入 gitignore) 记在开头的「Setup rulings」里。编号不连续是正常的(R31/R35 等条目按主题并入了相邻章节)。归档时间:2026-09-10。此后代码若再变,以代码为准;本文件只解释「当时为什么这么定」。
SDD ledger — plan: d:/cherry/docs/superpowers/plans/2026-09-10-ring-health-app-m0-m1.md
Spec: d:/cherry/docs/superpowers/specs/2026-09-10-ring-health-app-design.md (read, reachable) Workspace: d:/cherry/docs/.superpowers/sdd/2026-09-10-ring-health-app-m0-m1/
Setup rulings
Ruling: 不建 git worktree — 仓库尚不存在。Task 1 从零 git init 创建 repos/ring-health-app,
之后各任务直接提交到该仓库 main。Gitea 上的 ring-health-app 目前是空仓、无协作者,
计划 Task 1 Step 11 明确要求 git push -u origin main,用户已批准该计划。 — 代价:若判断错误,
需要把已有提交挪到新分支并强推空仓,成本很低。
Ruling: .superpowers/ 加入 docs 仓库 .gitignore(ledger 属 git-ignored scratch)。 — 代价:无。
环境已验证(无阻塞):JDK 17.0.12 在 PATH(JAVA_HOME 未设,Gradle 可用 PATH 上的 java);
ANDROID_HOME 已设;build-tools 35.0.0 / 36.1.0;platforms 31–36.1;
Gradle dists 缓存含 gradle-8.14-all(distributionUrl 指向该 URL 可命中缓存,无需联网下载发行包)。
Pre-flight conflict scan
跨任务:共享文件 / 接口
| 任务对 | 产出 → 消费 | 结论 |
|---|---|---|
| T1 ↔ T2 / T7 | T1 建 MainActivity.kt;T2、T7 先后改写 setContent |
OK,顺序覆盖,T7 版本取代 T2 |
| T1 ↔ T4 | T1 建最小 RingHealthApplication.kt;T4 替换 |
OK |
| T4 ↔ T6 | T4 的 DefaultAppContainer 引用 MockHealthDataRepository / MockRingDeviceRepository,二者到 T6 才存在 |
冲突 → R1 |
| T4 ↔ T5 | T5 的 :app:testDebugUnitTest 会编译整个 main source set(含 AppContainer.kt)→ 未解析引用,Step 4 期望的 PASS 拿不到 |
冲突 → R1 |
| T4 ↔ T8/T9/T10 | T4 的 AppViewModelProvider import HomeViewModel / WorkoutViewModel / DeviceViewModel,分别到 T8/T9/T10 才存在 |
冲突 → R2 |
| T5 ↔ T8/T12 | T5 的 metricDisplayName 把中文指标名硬编码在 Kotlin 里;T8 又把同一批名字加进 strings.xml。违反 Global Constraint「不硬编码用户可见文案」,且同一事实两处定义 |
冲突 → R3 |
| T7 ↔ T8 | HomeScreen(onOpenDetail, modifier) → T8 追加 viewModel 形参 |
OK |
| T7 ↔ T9 | T7 WorkoutScreen(modifier) → T9 追加 onOpenHistory;T9 在同文件内用 WorkoutHistoryScreen 替换 WorkoutHistoryPlaceholder |
OK |
| T7 ↔ T10 | T7 DeviceScreen(modifier) → T10 追加 onOpenSettings;同文件替换 DeviceSettingsPlaceholder |
OK |
| T7 ↔ T12 | T7 MetricDetailPlaceholder → T12 MetricDetailScreen,同文件 |
OK |
| T3 ↔ T8/T9/T10/T12 | 组件签名(HealthCard/RingProgress/MetricTile/SectionHeader/MetricBarChart/StatBar)在各调用点一致 |
OK,除 T12 的 Consumes 列了 StatBar 却未使用 → R4 |
T8/T9/T10/T11/T12 ↔ strings.xml |
五个任务都往同一文件追加 | OK,仅追加、key 互不重叠 |
| T13 ↔ 其他 | 无共享文件 | OK |
| T14 ↔ T1–T13 | 仅验收 | OK |
单任务:自身文本是否自洽
| 任务 | 结论 |
|---|---|
| T1 | 已修:android { kotlin {} } 嵌套块移到顶层(AGP 8.x 无此嵌套) |
| T2 | 自洽 |
| T3 | 已修:删掉不存在的 androidx.compose.foundation.layout.weight import |
| T4 | Step 5「暂不构建」与 Step 6 提交共存,但与 T5 的构建步骤矛盾 → R1/R2 |
| T5 | 测试期望 7 项含 metricDisplayName → R3 后为 6 项 |
| T6 | Step 7 期望全量构建通过,前提是 AppContainer 已存在 → R1 后成立 |
| T7 | 自洽 |
| T8 | 已修:LazyColumn 内调 @Composable;测试 WhileSubscribed 无订阅者 |
| T9 | 已修:formatCalories 加入 import 并实际使用 |
| T10 | 自洽 |
| T11 | 自洽 |
| T12 | 已修:factory() 两版写法只留正确一版 |
| T13 | 自洽 |
| T14 | 自洽(验收清单含「血压未开启」等关键态) |
Rulings
Ruling R1: 把 AppContainer.kt 的创建与 RingHealthApplication 持容器这两步,从 T4 移到 T6
(Mock 类建好之后)。T4 只产出 domain/model/HealthModels.kt + domain/repository/Repositories.kt。
—— 理由:容器是「把 Mock 接进 App」的动作,天然属于 Mock 那一任务;这样 T4–T6 每一步都能编译通过。
—— 代价:若判断错误,T4 的产出变小、T6 变大,只是任务边界调整,无返工。
Ruling R2: 把 AppViewModelProvider.kt 的创建从 T4 移到 T8(第一个消费者)。
T8 建立文件并只注册 HomeViewModel;T9 追加 WorkoutViewModel,T10 追加 DeviceViewModel。
—— 理由:viewModelFactory { initializer {} } 可增量追加,每个任务只引用已存在的 ViewModel 类。
—— 代价:若判断错误,三处 initializer 分散在本文件,仍是单一文件、单一职责。
Ruling R3: 从 T5 删除 metricDisplayName(连同其测试用例,测试数 7 → 6)。
T12 详情页标题改用资源 id 映射(metricTitleRes(type): Int → R.string.metric_*),
复用 T8 已加入 strings.xml 的同一批字符串。
—— 理由:Global Constraint 明令不硬编码用户可见文案;同一批中文名不能既在 Kotlin 又在 strings.xml 各写一份。
—— 代价:若判断错误,只是详情页标题多一次 when 映射,改动 5 行。
Ruling R4: T12 增加「睡眠专属区块」——当 metric == MetricType.SLEEP 时渲染 StatBar
(深睡/浅睡/清醒静态占比)+ 入睡/起床时间。同时把 T12 的 Consumes 里 StatBar 落实为真实使用。
—— 理由:spec §6 的 detail/sleep 明确要求「深睡/浅睡/清醒分段条 + 入睡/起床时间」,
而 T12 原本只做通用详情页,是 spec 覆盖缺口;顺带让 spec §7 要求的 StatBar 组件有真实调用点。
—— 代价:若判断错误,多出一个仅在睡眠详情显示的静态区块,可随时删除。
Ruling R5: compileSdk/targetSdk 取 36、buildToolsVersion 取 "36.1.0"、AGP 8.13.1、Kotlin 2.2.20、
Gradle 8.14 —— 均为本机已装/已缓存版本。spec §4 表格里的 35/AGP 8.11.x 是早期值,
以本计划 Global Constraints 为准(spec §11 风险行已说明工具链钉死在已安装版本)。
—— 代价:若判断错误,改 libs.versions.toml 四处版本号即可。
Progress
Task 1 — 仓库初始化 + Gradle 工程骨架
BASE: 不适用 —— repos/ring-health-app 由本任务 git init 创建,无前序提交。
审查包将用空树对象作为 BASE:git hash-object -t tree /dev/null。
Dispatch: implementer, model=sonnet(全新仓库首次 Gradle 构建,环境摩擦概率最高)。
Task 1 — 结果
Commit 2a90c18 chore: 初始化 Android 工程骨架。assembleDebug BUILD SUCCESSFUL (1m54s),产出 APK 18.6MB,
已推送 origin/main。审查包:review-task1.diff(BASE=空树 4b825dc6)。
Ruling R6: 接受依赖版本下调。brief 写死的 coreKtx 1.19.0 / lifecycle 2.11.0 / navigationCompose 2.10.1 / composeBom 2026.09.00 要求 compileSdk 37 + AGP 9.1.0,与 R5 钉死的工具链(compileSdk 36 / AGP 8.13.1)互斥, 首次构建 32 条 checkDebugAarMetadata 冲突即此因。实现者据各 AAR 自带 aar-metadata.properties 实测, 下调为 coreKtx 1.18.0 / lifecycle 2.10.0 / navigationCompose 2.9.8 / composeBom 2026.06.01(仅 4 行)。 工具链优先——R5 的理由是本机已装、避免下载。 — 代价:若判断错误,装 compileSdk 37 + AGP 9.1.0 后回改 4 行。
Ruling R7: 接受 Gradle 发行包目录联接(junction)。本机缓存的 gradle-8.14-all 哈希目录来自腾讯镜像 URL,
与 brief 的 services.gradle.org URL 哈希不同,会导致重下 224MB。实现者建联接指向已解压发行包,
gradle-wrapper.properties 未改。 — 代价:无(换机器克隆时表现为正常下载)。
Ruling R8: 补 .gitattributes(gradlew text eol=lf)。core.autocrlf=true 下新克隆的 gradlew 可能带 CRLF
而无法执行——Windows 上的真实隐患。纳入 Task 1 修复轮处理。 — 代价:无。
Task 1 — 审查结果(第 1 轮)
结论:Needs fixes。spec 合规逐项核过(23 文件与 brief 一一对应,local.properties 正确未入库,
厂商 SDK 三个拷贝文件 md5 与原件一致)。R6 被独立复核:逐个读缓存 AAR 的 aar-metadata.properties,
确认 brief 的 4 个版本确实要求 minCompileSdk 37 / minAGP 9.1.0,替换版本均为真实可解析 artifact。
修复轮 1 处理范围(裁定):
- 【Important-1】
MainActivity.kt:24Text("RingHealth")硬编码 →Text(stringResource(R.string.app_name))。 标注 plan-mandated:brief 的 Step 8 示例代码本身就这么写,计划该改。后续页面会照抄此模式,必须现在扳正。 - 【Important-2】
.gitignore补.kotlin/。 - 【Minor-3】
git update-index --chmod=+x gradlew+ 新增.gitattributes(gradlew text eol=lf、*.bat text eol=crlf)。这就是 R8,与实现者自陈的隐患 4 是同一件事。 - 【Minor-6】
libs.versions.toml的降级注释补上裁定编号与验证方法,防止后来者"好心"改回去重蹈 32 条冲突。 - 裁定:一并修(都是廉价且防回归的),不只修 Important 两条。
Park(不在本轮修):
- 【Minor-4】
screenOrientation="portrait"在 targetSdk 36 上对 ≥600dp 大屏失效 —— brief 指定, 手机场景符合预期,记录为已知决策即可。 - 【Minor-5】
testInstrumentationRunner无对应 androidTest 依赖 —— 当前惰性,等真有仪器测试再说。 - 【Minor-7】
ic_launcher_background.xml里唯一一处字面色值 —— 交给 Task 2 的 designsystem 收编。
环境变更(2026-09-10,非计划内,但影响后续所有任务的构建)
C 盘曾 100% 占满(149G/151G,仅剩 1.3G),是 Task 1 首次构建慢的直接原因。处理结果:
- 清理 Temp 中 7 天以上文件 + npm/pnpm/NuGet/.dartServer/pip 纯缓存。
- Gradle 用户目录已从
C:\Users\10314\.gradle整体搬到D:\.gradle,并在 C 盘原路径留了 目录联接指回 D 盘(LinkType=Junction)。任何进程无需设GRADLE_USER_HOME即可正常使用—— 子代理不继承新环境变量,靠联接才无感,后续不要"顺手"删掉它。 搬迁中 robocopy 首次以 exit 9 失败(native-platform.dll 被 Gradle daemon 占用), 已停 daemon 后补齐并收口,D 盘那份完整、发行包内部联接(services.gradle.org 哈希 → 已解压包)已重建。 - Android SDK 未搬(用户决定保留模拟器镜像与 NDK,且 C 盘已有 35G 可用)。
- 当前:C 盘 35G 可用 / D 盘 225G 可用。
教训:跨盘搬 Gradle 目录会因 daemon 加载 native-platform*.dll 而失败,须先 --stop 且确认无 java 进程;
robocopy 必须分两步(先 copy 验证完整,再删源建联接),不能直接 /MOVE。
事故:Android SDK 被永久删除(2026-09-10)
发现于 Task 1 修复轮的构建验证:./gradlew :app:assembleDebug 10 秒即 BUILD FAILED。
事实:
C:\Users\10314\AppData\Local\Android\整个目录消失,非搬走。- 全盘(仅 C: 与 D: 两个盘)搜索无
adb.exe/android-36/build-tools残留; 回收站 13 项全是 9/7–9/8 的旧文件,无 SDK → 属永久删除(绕过回收站)。 ANDROID_HOME与local.properties仍指向已不存在的旧路径。- Android Studio 本体仍在
C:\Program Files\Android,但无任何 SDK。
归因:无法确定。该窗口内对 C 盘做过清理的有两方——我(只删 Temp 中 >7 天的文件与 npm/pnpm/NuGet/.dartServer/pip 五个缓存目录)与 Task 1 实现者(自陈"排查中修复"过 C 盘)。 两方都无法排他性证明。不深究归因,重点是恢复与防复发。
恢复(用户裁定:装到 D 盘、只装必需组件):
- 经
commandline-tools装platform-tools+platforms;android-36+build-tools;36.1.0到D:\Android\Sdk。 - 改
local.properties的sdk.dir与用户级ANDROID_HOME/ANDROID_SDK_ROOT指向新路径。 - 模拟器、system-images、NDK 不重装(用户选择;真要用再装)。
防复发规则(后续任务一律遵守):
- 不再清理 C 盘。用户已明确"不用腾 C 盘空间了"。
- 任何删除操作前必须先
Get-Item确认目标,并优先"先复制验证、再删源"。 - 绝不使用会把大目录卷入的通配路径删除。
Task 1 — 修复轮 1 完成
Commit 3cbcafd fix: 文案改用字符串资源,补齐 .gitignore/.gitattributes 与依赖降级依据。
./gradlew :app:assembleDebug --no-daemon → BUILD SUCCESSFUL in 23s(compileDebugKotlin 真实执行)。
落地核验(实现者自报,待复审确认):git ls-files -s gradlew = 100755;gradlew 首行以 LF 结尾;
git check-ignore -v .kotlin 命中 .gitignore:7。已推送 2a90c18..3cbcafd。
复审包:review-task1-fix.diff(BASE=2a90c18, HEAD=3cbcafd)。作用域内复审已派发。
Task 1 — 完成 ✅
复审裁定:全部四项 ADDRESSED,无新增 Critical/Important 破坏。
- Important-1 已修:
MainActivity.kt:26=Text(stringResource(R.string.app_name)),import 在 :14。 - Important-2 已修:
.gitignore:7含.kotlin/。 - Minor-3 已修:diff 显示
gradlewold mode 100644 / new mode 100755(零行内容变更);.gitattributes确为新增,含gradlew text eol=lf与*.bat text eol=crlf。 - Minor-6 已修:
libs.versions.toml:4-17补上勿改回警示、R5/R6 裁定编号、 以及「依据各 AAR 的 META-INF/.../aar-metadata.properties 实测」的验证方法。
Ruling R9: 修复计划文档本身的缺陷。复审查出 plan 第 398、634 行仍写着 Text("RingHealth") 字面量——
Task 1 只改了代码没改文档,而 Task 2 的 Step 4 正要照抄这段。已在计划中改为
stringResource(R.string.app_name)、补 androidx.compose.ui.res.stringResource import,
并在 Task 2 处加了一段明确警示(说明 Task 1 初版正因照抄此示例而引入缺陷)。
同时从修订后的计划重新生成 task-2-brief.md,确保实现者拿到的是修正版。
—— 理由:文档里的示例代码就是后续任务的权威来源,只修代码不修文档,缺陷必然复发。
—— 代价:无。
Task 2 — 设计系统(颜色/字体/主题)
BASE: 3cbcafd(Task 1 修复轮后的 HEAD)
Commit 8c245c0 feat: 新增设计系统(颜色/字体/浅色主题)。assembleDebug BUILD SUCCESSFUL in 23s,无告警。
4 文件(+80/-2):新增 core/designsystem/{Color,Type,Theme}.kt,改 MainActivity.kt。
实现者自陈两点小顾虑:Theme.kt 沿用 brief 的全限定 androidx.compose.ui.graphics.Color.White 未 import;
只做了静态构建验证,未做视觉核对(Task 3+ 才有意义)。→ 交审查者判定。
审查包:review-task2.diff。
Task 2 — 完成 ✅
复审裁定:Approved(规范符合,无 Critical/Important)。14 个颜色十六进制、2 个字号、2 个字重、
4 个文件名、RingHealthTheme 签名——全部逐字节对照 brief 与计划核实,无静默改值。
R9 的硬编码文案先例保持住了(MainActivity.kt:32 仍是 stringResource(R.string.app_name))。
BASE=3cbcafd,HEAD=8c245c0,审查包 review-task2.diff。
Ruling R10: 计划全局约束里的「常量用 UPPER_SNAKE_CASE」与设计系统 token 的 PascalCase
(BrandGreen/MetricRed/AppBackground/MetricValueStyle…)直接冲突。审查者指出这会贯穿剩余
十个任务、每轮审查都重复报同一条。已改计划:把该约束限定为 const val 编译期常量,并显式豁免
core/designsystem/ 的设计 token(它们不是 const val,且 Tasks 3–12 按确切名字引用,改名即破坏接口契约)。
—— 理由:消除一处会让后续每轮审查都重复产出的假阳性,同时把"为什么可以不一致"写清楚。
—— 代价:无。
Park(不修,留给最终整分支审查):
Theme.kt:9用全限定androidx.compose.ui.graphics.Color.White而非 import —— 纯风格,无功能风险, 为它单开一轮构建+提交不划算。审查者指出它会成为 Tasks 7–12 的风格先例,记录备查。Type.kt:9,16的MetricValueStyle/MetricTileValueStyle未注入RingHealthTypography—— brief 要求如此。MainActivity.kt:28+enableEdgeToEdge()在系统暗色模式下可能有状态栏对比度问题 —— brief 指定,暗色明确推迟到 M5;RingHealthTheme缺darkTheme: Boolean参数,M5 落地时各调用点会有签名变更。- 报告 diffstat 把
MainActivity.kt写成 +8/-2,实际 +6/-2 —— 仅报告误差,代码无误。
Task 3 — 通用 UI 组件
BASE: 8c245c0。6 个组件文件:HealthCard / RingProgress / MetricTile / SectionHeader /
StatBar(含 StatSegment)/ MetricBarChart。产出签名是 Tasks 7–12 全部页面的构件。
Task 3 — 结果
Commit 75a6749 feat: 新增通用 UI 组件。assembleDebug BUILD SUCCESSFUL;--rerun-tasks 强制重编
零 Kotlin 告警。6 个新文件 330 行,未触碰任何既有文件。BASE=8c245c0,审查包 review-task3.diff。
Ruling R11: 接受实现者对计划代码缺陷的修正。计划的 MetricBarChart 用
values.indexOf(maxValue) 定位高亮柱,但 maxValue 经过 coerceAtLeast(1f) 抬高,
只要所有值都 < 1(如睡眠时长 0.5h)就返回 -1 → 高亮柱永不出现。改为
values.indices.maxByOrNull { values[it] } ?: 0。公开签名与默认值未变。
—— 理由:这是计划里的真 bug,不是风格分歧;审查者应判"修正正确"而非"偏离 brief"。
—— 代价:无。另两处非签名偏离(KDoc 措辞与 highlightLast 默认值不符、删掉未使用的 import)一并接受。
Ruling R12: 保留 brief 的 MetricTile(onClick: () -> Unit = {}) 非空签名,不改为可空。
—— 理由:Task 8 的所有指标块都指向 Task 12 的指标详情页,不存在"无处可去"的块;
连"血压未开启"也应能点进去看到未开启说明(与小米运动健康一致)。改成可空是为不存在的情况加复杂度(YAGNI)。
—— 代价:若判断错误,改一处签名 + 各调用点补 null,约 5 行;届时若确需静默块再改。
Task 3 — 审查
已派发任务审查者(general-purpose / sonnet),输入:task-3-brief.md、task-3-report.md、
review-task3.diff(BASE 8c245c0 / HEAD 75a6749)、Global Constraints 原文,
并把 R11(MetricBarChart 高亮位置修正为已批准)与 R12(MetricTile onClick 保持非空)作为
已裁定事项写入派发词,避免被当成偏离 brief 的缺陷而触发无谓的修复轮。
Task 4 brief 已生成:task-4-brief.md(领域模型 + 仓库接口)。Task 4 的 BASE 待 Task 3 定稿后取
(若 Task 3 触发修复轮,BASE 顺延至修复提交)。
Task 3 — 审查结论:Approved(无 Critical / 无 Important)
审查者逐条核对了 6 个签名与默认值(含参数顺序)→ 零漂移,Tasks 7–12 可按名调用。
几何自行重算而非采信报告:RingProgress 弧框 166dp + 7dp 内缩,描边 14dp 恰好贴合、四边不裁切,
sweep 0f→360f 且 coerceIn 已处理越界;MetricBarChart 槽宽 65%/35% 对称、底对齐、
空列表在除法之前就 return、两个 highlight 分支都恰好高亮一根柱。R11 的替换被判"正确且完整"。
Park(已批准,不在本任务修,留最终整分支审查):
- M1
RingProgress.kt:61:progress = 0f时 sweep 为 0 且 Round cap,Skia 是否画出 14dp 圆点 未经运行验证(审查者自评 ~70% 置信)。观察点:若 Task 8/14 在 0 步数下真看到色点, 修法是progress > 0f才画进度弧(约 2 行)。 - M4
SectionHeader.kt:29-36:SpaceBetween+ 未加权的标题与 trailing,超长标题(文案来自 strings.xml,长度不可控)可能把 trailing 挤没。签名中性的修法:标题加Modifier.weight(1f, fill = false)或maxLines = 1+ ellipsis。观察点:Task 8 起各页 实际标题长度。 - M2 NaN progress 会静默丢失进度弧(不崩、不画错);M3 全 <1 或全 0 序列下柱图高度利用率低/全空白;
M5
MetricTile里单 Row 外包Column(Bottom)是空操作;M6StatBar里.fillMaxWidth()冗余; M7 六个组件均无@Preview。均为打磨项,不动。
Ruling R13 — core/common/Formatters.kt 的「不硬编码文案」边界(派发 Task 5 前裁定)
起因:Task 5 brief 的代码把 "$kcal 千卡"、"放松/正常/中等/偏高"、"7小时32分" 直接写在 Kotlin 里,
与 Global Constraints「所有用户可见文字进 strings.xml」正面冲突;且 R3 已用同一条理由
(「会与 strings.xml 里的第二份形成两份事实来源」)把 metricDisplayName 排除在外 —— 计划自相矛盾。
裁定(已改计划,见 commit):
formatCalories(kcal)只返回千分位数字;「千卡」由调用点用资源拼接。 依据:R.string.home_calories_label已是「千卡」,硬编码即 R3 所说的两份事实来源。R.string.workout_detail_calories由消耗 %1$s改为消耗 %1$s 千卡(唯一调用点,2742 行)。stressLevelLabel(stress): String→@StringRes stressLevelLabelRes(stress): Int, 4 条中文描述进 strings.xml。依据:与 R3 对指标名的处理保持同一模式。- 豁免:
formatSleepDuration(7小时32分)与formatDuration(1小时05分)保留。 依据:数字与单位黏合成单一复合值,设计系统没有独立单位槽位可放,拆开会把同一段when复制进每个调用点;且「小时/分」在 strings.xml 里没有第二份。豁免已写进 Global Constraints, 并写明判据(该文字是否已有第二份资源 + 设计系统是否已提供独立槽位),避免后续审查重复误报。 代价:Task 5 两处测试断言改写;strings.xml 新增 4 条;Task 8 首页 calories 由.toString()改走formatCalories()(顺带消掉 Task 8 代码块里原本未被使用的formatCaloriesimport)。 若判断有误,回退范围约 4 个文件、10 行以内。
Ruling R14 — Task 7 占位页的中文字面量(同批修正)
起因:Task 7 的 7 个占位 Composable 写成 Text("健康首页") / Text("设备设置") / Text("详情:${metric.name}"),
而同一个 Task 的 Step 3 正好新增了 tab_home/tab_workout/tab_device/tab_profile —— 资源就在手边没用。
这正是 Task 1 首轮审查判为 Important 的同一类缺陷(Text("RingHealth") 字面量),必然重复触发修复轮。
裁定(已改计划):四个 Tab 占位页改用 stringResource(R.string.tab_*);三个子页占位新增
placeholder_device_settings / placeholder_workout_history / placeholder_metric_detail
(详情:%1$s 简化为固定文案「指标详情」,避免占位页依赖 Task 12 才有的 metricTitleRes);
7 个文件补齐 stringResource 与 R 的 import。函数名与签名不变,Tasks 9/10/12 的替换点不受影响。
代价:Task 7 多 3 条字符串资源;占位页文案由「健康首页」统一为「健康」。若判断有误,回退 7 个文件。
Task 4 — 结果
Commit ea8897f feat: 新增领域模型与仓库接口。两文件 105 行,未触碰既有文件。
assembleDebug BUILD SUCCESSFUL(compileDebugKotlin 为 executed,非 up-to-date)。
BASE=75a6749,审查包 review-task4.diff。
Task 4 — 审查结论:Approved(无 Critical / 无 Important)
审查者逐字段核对了 7 个数据类与 2 个接口:字段名、类型、声明顺序、可空性(6 个可选指标确实可空、
4 个常态字段确实非空)、参数名与返回类型全部与 brief 一致,零漂移;KDoc 覆盖到每个公开成员
(含接口属性);domain/ 无 android.*/androidx.* import;R1/R2 的范围裁定被遵守,
没有偷建 AppContainer.kt/AppViewModelProvider.kt。另跑三项针对性编译风险检查(重名声明、
coroutines 传递依赖、行尾)均无阻塞。
Park(不在本任务修):
Repositories.kt:14-15的kotlinx.coroutines.flow.*只是传递依赖(经 compose-runtime 的 api variant),app 模块没显式声明 coroutines。今天能编,但domain/的编译类路径因此依赖 Compose —— 正是「domain 层可脱离 Android 独立编译」想避免的耦合。M2 若把domain/拆成纯 Kotlin 模块, 必须同时加显式依赖。现在不动:改libs.versions.toml(带禁止回退告示)去引一个已被传递解析的 版本,冲突风险大于收益。Repositories.kt:40setFunctionEnabled非 suspend,而refreshDeviceState是 suspend。 M2 的真实 BLE 写是异步的,届时适配器要么自建 scope 要么阻塞。brief 明确要求此签名,留给 M2。
Task 5 — 派发
BASE: ea8897f。brief 已按 R13 重新生成(task-5-brief.md,182 行,含新的 stressLevelLabelRes
与 strings.xml 步骤)。派发词携带:真实源码根路径、R 全限定名 com.placeholder.ringhealth.R、
@StringRes 由 androidx.annotation 传递提供、R13 的裁定理由(避免实现者"好心"改回拼接单位)。
Ruling R15 — Mock 数据层的中文字面量:豁免(派发 Task 6 前裁定)
Task 6 的 Mock 里有 listOf("户外跑步", "健走", …)、name = "RingHealth 智能戒指"、
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true) 等中文。
裁定:不进 strings.xml,保持原样。
理由:这些是设备侧数据的替身,不是 App 的 UI 文案 —— M2 接入后设备名来自蓝牙广播/固件、
运动类型与功能项来自协议与适配层。塞进资源反而会逼 M2 的适配器把设备值反向查表映射回资源 id。
R13 定的两条判据在这里都不成立:strings.xml 里没有第二份,设计系统也没有对应槽位。
—— 代价:若 M2 的设备只给 id 不给展示名,适配层需自行从资源解析(那属于 M2 的改动,不在 Mock)。
给 Task 6 审查者的说明:这是已批准的决定,请勿作为"硬编码文案"上报。
计划通扫(R13/R14 之后的复检)
按 Text("中文") / = "中文" / listOf("中文") / -> "中文" 四种形态扫全计划,剩余的 Kotlin 中文字面量只有:
①Formatters 的 formatSleepDuration/formatDuration 及其测试(R13 明确豁免);
②Task 6 的 Mock 数据(R15 豁免);③第 656 行那句解释 R9 的说明性注释(非代码)。
其余全部已进 strings.xml 或改用资源引用。据此判断:Tasks 8–12 不应再出现 R13/R14 类缺陷。
Ruling R16 — 工作直接落在 main,不做 worktree / 特性分支
现状:/d/cherry/repos/ring-health-app 当前分支 main,upstream 为 origin/main,本地领先 4 个提交
(8c245c0 75a6749 ea8897f bf373a2;origin 停在 3cbcafd)。
裁定:Task 1–14 全部直接提交到本地 main,不另开分支或 worktree。
理由:这是用户为本项目新建的仓库,Phase 1 从零开始,main 就是本项目的开发主线,
不存在需要保护的既有共享分支;SDD 的 ledger 以提交 SHA 记录 BASE/HEAD,线性历史正是它需要的。
推送(git push)属四类停止条件里的「对外动作」,但用户此前已明确给出仓库地址并要求推送
(http://120.192.101.56:3000/cuiluzheng/ring-health-app.git),该授权持续有效。
节奏:不在每个 Task 后推,留到审定节点(Task 5 审查通过后)成批推一次,保证 origin 不长期落后。
Task 5 — 结果
Commit bf373a2 feat: 新增数值格式化工具(含单元测试)。3 文件 +109 行。
测试 6/6 通过,输出无告警;RED 阶段确认真实(实现前 21 处 Unresolved reference,BUILD FAILED)。
strings.xml 为纯追加 4 条,未新建文件。BASE=ea8897f,审查包 review-task5.diff。
实现者提出的两点疑虑:formatClock/formatDate 无测试(brief 只指定 6 个测试,属 brief 规定范围),
以及 Locale.CHINA 固定分隔符 —— 均为已知取舍,转交审查者按 Minor 判定。
Task 5 — 审查结论:Approved(无 Critical / 无 Important)
审查者把 6 条测试的期望值全部自行重算(含 formatDistance(6.212) 的舍入方向 —— 第三位是 2,
HALF_UP 与截断同解,无陷阱;formatSleepDuration(480) 走「整小时无分钟」分支;
formatDuration(65) 的 %02d 补零),并逐条验证无同义反复(打乱任一分支臂或返回常量 id 都会挂)。
R13 的豁免被精确使用且未扩大:实现里仅剩 小时/分 两处中文,千卡 只出现在 KDoc 里,
放松/正常/中等/偏高 在 Formatters.kt 中完全不出现。
RED 证据被判为真实:审查者按提交的测试文件反推 21 处编译错误的行:列,与报告逐条吻合 ——
伪造的输出不会刚好落在每一列上,且报错点名了 R.string.stress_*,只有 strings.xml 未改动时才成立。
Park(不在本任务修):
Formatters.kt:48-50压力分档阈值未写进 KDoc(30→正常、60→中等、80→偏高 是开区间下界)。formatSleepDuration/formatDuration传负数会得到"0分"/"-30分"这类无意义值;本领域不会出现负分钟。formatDistance(NaN)会渲染出英文"NaN"(Java Formatter 特例,不抛异常)。formatClock/formatDate无测试(brief 只指定 6 个);将来补测时SimpleDateFormat用的是 JVM 默认时区, 必须显式固定时区,否则换机器会 flake。- 每次调用各自 new 一个
SimpleDateFormat:无共享状态、无线程安全问题,仅是每次一个小分配。改成静态共享反而会引入线程安全 bug。 - 报告第 66 行说「8 个符号此时全部不存在」属叙述夸大(RED 日志只证明 6 个函数 + 4 个资源 id)。
Ruling R17 — MockDataProvider 注入时间源(派发 Task 6 前裁定)
起因:本类 KDoc 承诺「所有方法都是确定性的(同参数返回同结果)」,但 metricHistory/workouts
直接读 System.currentTimeMillis(),timestamp 随之跳动。brief 的测试
assertEquals(first, second) 比较的是整个 List<MetricPoint>(含 timestamp)—— 两次调用只要跨过
一个毫秒边界就失败,是一条间歇性 flaky 断言(Windows 上 currentTimeMillis 的粒度更粗,
平时容易蒙混过关,换机器或负载变化就会现形)。
裁定:给 MockDataProvider 增加可注入时间源 now: () -> Long = System::currentTimeMillis,
内部一律用 now();测试注入 FIXED_NOW,并补一条「时间戳严格递增」的断言
(顺带验证「每点间隔一天倒推」的符号方向没写反)。
—— 理由:这样 KDoc 的确定性承诺才成立,测试也才真正在验证「可重复」而不是在赌毫秒边界。
—— 代价:接口增补一个带默认值的参数,brief 的 Produces 行与测试各改一处;实现端零额外复杂度。
若判断有误(比如并不在意时间戳稳定性),回退就是删掉该参数并放宽断言,约 6 行。
Task 6 — 结果
Commit fda1040 feat: 新增 Mock 数据层与依赖容器(含单元测试)。6 文件 +283/-2。
5/5 MockDataProviderTest 通过,全量单测 11/11(Formatters 6 + Mock 5),assembleDebug SUCCESSFUL,输出无告警。
RED 已捕获:Unresolved reference 'MockDataProvider'(MockDataProviderTest.kt:15:28),其余 6 条错误均由其级联。
R1(AppContainer 落在本任务)、R15(Mock 中文留在 Kotlin)、R17(注入 now())均已执行。
data/mock/ 与 AppContainer.kt 无 android.*/androidx.* import(已 grep 确认)。BASE=bf373a2。
Ruling R18 — override 成员不重复写 KDoc(随 Task 6 审查一并提交)
MockHealthDataRepository / MockRingDeviceRepository 的 override 成员按 brief 原样、未加 KDoc,
实现者主动把这一点提出来询问。
裁定:保持现状。理由:KDoc 在 Kotlin 中沿继承链生效,接口 Repositories.kt 已为每个成员写了中文说明,
在实现处复制一遍只会产生两份需要同步维护的说明;真正属于实现细节的信息(「每次发射前 delay 200ms」、
「开关状态存内存、重启即重置」)已写在类级 KDoc 上,位置正确。
—— 代价:若审查者按「每个公开函数上方都要有 KDoc」的字面要求上报,此为已批准决定,不再开修复轮。
交给 Task 6 审查者的待判问题(不预设结论)
MockRingDeviceRepository.refreshDeviceState() 用 _deviceState.value = provider.deviceState() 赋值,
而 provider.deviceState() 每次返回相等的 data class 实例 —— MutableStateFlow 按相等性抑制重发,
因此「刷新」不会产生任何可观察的发射。请审查者判定:这在 M1(数据恒定、无真实设备)下是合理行为,
还是应当视为缺陷(例如改为带时间戳的状态、或让 UI 的刷新反馈不依赖状态变化)。
Ruling R19 — 修正 Task 7 的 Produces 契约(派发 Task 7 前裁定)
起因:Task 7 的 Interfaces 段写 HomeScreen(onOpenDetail: (MetricType) -> Unit, onOpenWorkoutHistory: () -> Unit),
但全计划搜不到 onOpenWorkoutHistory 的任何调用点;Task 8 实际产出
HomeScreen(onOpenDetail, modifier, viewModel)。同时 WorkoutScreen(onOpenHistory) 与
DeviceScreen(onOpenSettings) 分别是 Task 9 / Task 10 才加入签名的(它们在各自 Step 里更新
RingHealthApp.kt 的调用点),Task 7 的占位页并没有这两个参数。
影响:本任务的实现者与审查者都以 Interfaces 段为准 —— 照它写会凭空多出一个没人用的参数,
照代码写又会被审查者判「缺少声明的参数」,两条路都要浪费一轮。
裁定:改计划,Interfaces 段改为描述本任务实际产出的占位签名,并写明
「DEVICE_SETTINGS / WORKOUT_HISTORY 本任务只注册、无人跳转,回调由 Task 9/10 加入」,
以及「onOpenWorkoutHistory 是笔误,已删除,不要加回来」。
—— 代价:无。若判断有误(比如后续确实需要首页跳运动历史),届时按需在 Task 8 添加即可。
Task 6 — 审查结论:Approved(无 Critical / 无 Important)
审查者复算了确定性(now() 只读一次、Random 每次按同种子重建、逐点抽样序列与 count 无关)、
时间戳符号(count=3 时 index 0 = now-2天,确认「最早在最前」没写反)、种子防碰撞
(ordinal*100 + range.ordinal 在 7 指标 × 2 范围上两两不撞,seed+7 也不与它们冲突)、
coerceIn 的 7 个区间均升序、setFunctionEnabled 的 map+copy 语义、以及 DI 初始化顺序
(manifest 无 <provider>,lateinit 不可能被提前读取)。RED 的行:列同样被逐条反推吻合。
待判问题裁定:refreshDeviceState 不产生发射是正确的 StateFlow 语义,不是缺陷 ——
相等值抑制正是 StateFlow 作为状态容器的定义行为,M2 蓝牙重读到相同电量时也不应churn UI;
刷新契约由 suspend 函数「返回」而非「发射」来表达,delay(300) 在 suspend 体内,
调用方 launch { refresh(); isRefreshing = false } 在 M1 与 M2 都成立。
但审查者指出一个调用侧陷阱:若有人把刷新反馈写成 deviceState.drop(1).first() 之类的
「等下一个不同发射」,在 M1 会永久挂起,且看起来像仓库的锅。已 grep 确认当前没有任何 UI 调用
refreshDeviceState(只有接口声明与 Mock 实现),所以这是预防性记录,留给将来加刷新入口时。
Park(不在本任务修):3 条「值合理」断言对当前实现无法证伪(coerceIn 蕴含区间、
batteryPercent in 0..100 是字面量 78 蕴含、durationMinutes>0 由 25+ 蕴含),作为冒烟断言可接受;
MockRingDeviceRepository 整类无测试;.gitattributes 无 *.kt 规则(core.autocrlf=true 导致的
LF→CRLF 提示为既有行为,blob 仍归一化为 LF);RingHealthApplication 的 KDoc 比 brief 少了一句
「(M1 阶段只持有 Mock 容器)」;metricHistory 的 KDoc 说「波动」而公式实为自低到高的斜坡。
Ruling R20 — 补一轮小修复:让 R17 的时钟注入可被证伪(Task 6 修复轮 1)
起因:审查者指出(Minor 1),R17 注入的时钟没有被测试真正验证 ——
assertEquals(first, second) 的两次相邻调用几乎总落在同一毫秒,把 now 去掉、
退回 System.currentTimeMillis(),这条测试照样通过。也就是说我为消除 flaky 而做的改动,
本身处于无覆盖状态,且该测试无法在将来发现这次改动被回退。
裁定:开一轮小修复(不因它是 Minor 就放过 —— 它保护的是本次会话自己引入的改动):
①测试补两条绝对时间戳断言(first.last().timestamp == FIXED_NOW、
first.first().timestamp == FIXED_NOW - 6 天),使注入成为被测对象;
②顺带把审查者 Minor 4 的刷新语义写进 MockRingDeviceRepository 的类级 KDoc
(「以 suspend 返回表示完成,不靠发射新状态」),因为已经要动这个文件,且它能挡住那个调用侧陷阱。
—— 代价:一轮构建 + 一次范围化复审。若判断有误,回退就是删掉两条断言。
修复轮 1 结果: 提交 2fba18b(+11 行,仅 2 个文件:MockDataProviderTest.kt 与 MockRingDeviceRepository.kt)。
MockDataProvider.kt 相对 fda1040 零 diff,currentTimeMillis 在 data/mock/ 主源码中只剩构造参数默认值一处。
装药检查(要求实现者真做,不是推理): 把 metricHistory 内部退回挂钟后,测试失败
expected:<1760000000000> but was:<1789008526157> —— 而同一次运行里旧的 assertEquals(first, second) 依然通过。
这是对审查者诊断的实证:两次相邻调用确实落在同一毫秒,旧的相等断言在任何情况下都发现不了注入失效。
两条新断言是真正承重的。恢复后 5/5 通过;--rerun-tasks 强制重跑全量 11/11;assembleDebug 成功。
范围化复审:CLEAN。 复审者独立复算了时间戳(count=7,index 6 → 恰为 now(),index 0 → 恰为 6 天前,
6L*24*60*60*1000 = 518,400,000 量级与符号均正确),确认 KDoc 的 [refreshDeviceState] 链接指向真实同类成员,
并逐条核验了实现者自述(零 diff、diffstat、currentTimeMillis 计数)—— 均属实。
Task 6: complete — 提交 fda1040 + 修复轮 2fba18b
Ruling R21 — Task 7 跳过 installDebug,仅 assembleDebug
起因:brief 的 Step 7 写的是 ./gradlew assembleDebug && ./gradlew installDebug,
Expected 还要求「打开 App 可见底部四个 Tab,点击可切换」。但本机没有模拟器、也没有连接真机
(CLAUDE.md 明载 Android SDK 只装了 platform-tools / platforms;android-36 / build-tools;36.1.0,无模拟器与 NDK)。
installDebug 必然失败,且失败原因与本任务代码无关 —— 若不预先裁定,实现者会把它当成自己的 bug 反复调试,
或者报 BLOCKED。裁定:Task 7 只跑 assembleDebug;「四个 Tab 可点、互不崩溃」这一条降级为编译期验证
(NavHost 路由全部注册、Routes 常量与 composable(...) 一一对应),运行时验证顺延到 Task 14 的端到端验收,
或在有设备时人工补做。这也顺带剔除了全部 Task 里所有 installDebug 步骤。
—— 代价:导航骨架在 Task 14 之前没有真机验证;若 Task 14 也无设备,则整个 M1 的运行时行为都只有编译期保证,
那时需要单独向人确认(是否装模拟器镜像,约 1–2 GB,落在 D 盘)。这条风险记在此处,不隐藏。
Task 7: complete — 提交 6d3cb1b(4 Tab 导航骨架)
审查结论:Approved with minor issues。 无 Critical、无 Important。审查者独立复算了三处最容易出错的地方:
路由模板与拼串是否吻合("detail/{metric}" 对 "detail/STEP",逐段匹配后 arg="STEP" → MetricType.STEP;
并核验 7 个枚举名全为 [A-Z_],不含 / 或 %,永远不需要 URI 编码)、
底栏回退栈语义(popUpTo(startDestination){saveState} + launchSingleTop + restoreState 逐跳推演:
HOME→WORKOUT→DEVICE 不堆积;重复点已选中 Tab 时先 pop 再被 launchSingleTop 抑制,栈不变、不崩)、
图标依赖(从 debugCompileClasspath 实证 material-icons-extended:1.7.8 在线上,
FitnessCenter 只在 extended 里,这正是它被显式钉版本的原因)。
另外强制 --rerun-tasks 全量重编,0 warning,实现者自述属实。
5 条 Minor 全部是「计划缺陷」而非「实现缺陷」(brief 把这批代码逐字钉死了):
F1 Routes.kt 里模板占位符名、DETAIL_ARG_METRIC、detail() 拼串是三份手工同步的字面量;
F2 BottomTab.labelRes: Int 缺 @StringRes;F3 子页在栈顶时四个 Tab 全不高亮
(destination.route 返回的是模板,不等于任何 Tab 路由);F4 ?: MetricType.STEP 会静默显示错数据;
F5 报告里「导航测试需要设备」的说法过宽 —— Routes 与 MetricType 是纯 Kotlin,JVM 测试完全够。
Ruling R22 — 把 F1/F3/F4/F5 折进计划文本,不在 Task 7 开修复轮
裁定:不开修复轮。理由是这四条目前全都不可观测:没有任何代码导航到子路由
(HomeScreen 是占位、Task 9/10 才加 onOpenHistory/onOpenSettings),
此时改 RingHealthApp.kt 属于改一段测不到的死代码,改对了也验证不了。
④ 它们真正变得可见的时刻,正是 Task 9(第一个子路由导航接入)、Task 10、Task 12。
因此把修复写进那些任务的计划文本,让改动与它的验证同处一个提交:
①F1 的加固(DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}",注意常量声明顺序)+
②F3 的 tabRouteFor 归属映射(三个子路由一次性映射完,免得 Task 10/12 回来改同一个 when)
两条并入 Task 9 Step 5;③F4 的 requireNotNull 替换静默兜底并入 Task 12;
④F5 的 RoutesTest(纯 JVM、不需要设备)一并并入 Task 12。
—— 代价:Task 7 的提交里留着一处已知不完美的 Tab 高亮,直到 Task 9 才修;若 Task 9 被取消或重排,
这三条会跟着丢失(已在本条与计划文本两处留痕,不会只剩口头记忆)。
F2(@StringRes)不值得单独动,Park 到 M2 统一收口。
Ruling R21(补充执行)— 已把 installDebug 从全部 Task 的构建步骤中移除
除 Task 1(已完成的历史文本)外,Task 8–13 的 ./gradlew assembleDebug && ./gradlew installDebug
一律改为只跑 assembleDebug;Task 14 Step 3 的人工验收清单加了三行醒目说明,
要求执行 agent 原样抄进报告并标注「未执行 —— 无设备」,不得当成已通过。
Global Constraints 也加了对应一条,让后续每个 Task 的 brief 都带上它。
Ruling R23 — Task 8 的 unit = "mmHg" 是硬编码用户可见文案,改为字符串资源
派发前预读 brief 时发现:metric_blood_pressure 卡片的单位写成字面量 "mmHg",
而同一个 MetricCardSpec 的兄弟单位 —— 次/分、%、°C —— 全都规规矩矩走了 strings.xml。
unit 会被 MetricTile 直接渲染出来,所以 "mmHg" 是用户可见文字,按 Global Constraints 必须进资源;
同一份 listOf 里六个卡片、五个合规一个不合规,正是 R13 要消灭的那种自相矛盾。
若放过,它会成为「写死也行」的先例,而 Task 12 的详情页要复用同一批单位资源。
裁定:计划里改为 stringResource(R.string.unit_mmhg),并在 Step 7 的字符串清单中补
<string name="unit_mmhg">mmHg</string>。
—— 代价:多一条字符串资源。若判断有误(有人认为纯符号单位不必进资源),删掉这一条即可,无其他影响。
另外把 Step 8 的标题与 Expected 从「真机验证」改为「构建 + 单测」,界面验证按 R21 顺延到 Task 14。
派发前已逐项核验 Task 8 brief 引用的全部符号真实存在且签名一致(这是最容易一编译就炸的地方):
MetricGreen/Red/Blue/Cyan/Purple/Orange/Yellow(Color.kt:17-23)、TextSecondary(:13)、
MetricValueStyle(Type.kt:9)、HealthCard(modifier, onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit)、
MetricTile(icon, iconTint, title, value, unit, modifier, onClick)、
RingProgress(progress, color, trackColor, modifier, ringSize, strokeWidth, content)、
SectionHeader(title, modifier, trailing) —— 全部吻合。
collectAsStateWithLifecycle 所需的 lifecycle-runtime-compose、
viewModel(factory=) 所需的 lifecycle-viewmodel-compose、viewModelFactory/initializer 所需的
lifecycle-viewmodel-ktx 三者均已在 app/build.gradle.kts:54-56 声明(2.10.0),Task 8 不需要新增依赖。
androidx.compose.foundation.lazy.{item,items} 的 import 路径正确,且 foundation 经
HealthCard 已在用的 androidx.compose.foundation.clickable 确认在编译类路径上。
Ruling R24 — 我自己核验错了:androidx.compose.foundation.lazy.item 不可 import
派发 Task 8 前的预读里,我核验了设计系统的全部符号与依赖(都对),并在派发词里写了
「androidx.compose.foundation.lazy.{item,items} 的 import 路径正确,不要改动」。这句话是错的。
实现者没有照做,而是去 javap 反编译了实际解析到的产物
(compose-bom 2026.06.01 → foundation-android 1.11.4)核实,确认:
item 是 LazyListScope 的成员函数,不是顶层扩展 —— 成员函数永远不能 import,
那一行单独就让编译失败(HomeScreen.kt:15:41 Unresolved reference 'item');
只有 items(List<T>) 是 LazyDsl 里的顶层扩展,才需要 import。
实现者删掉那一行后构建通过,且 item { } 的调用点行为不变(改走成员分派)。
这是本次执行中第一次「实现者推翻控制者的指令且实现者是对的」。 处理方式:
照实现者的结论改计划(删掉该 import),并在计划里留下 R24 的显式警告,
避免后来者把这一行「补回去」;同时把这条记在这里 ——
我预读时把「这两个名字我都见过」当成了「这两个都能 import」,那是记忆而非核验,
而真正的核验(反编译看符号)只花了实现者一次 javap。
教训写进流程: 派发词里凡是我用「不要改动」这种强断言背书的符号,必须是我实际反编译或编译验证过的,
否则改为「若编译不过,以实现者实测为准并回报」。
顺带确认:全计划只有这一处该 import(Task 9/10/11 没有),已修。
关于实现者的 concern 3(formatSleepDuration 返回硬编码中文)—— 这是已裁定事项,不是新缺陷。
R13 已把它连同 formatDuration 一起写成 Global Constraints 里的显式豁免,判据有两条
(该文字是否已有第二份资源 + 设计系统是否已提供独立槽位),睡眠时长两条都不满足。
实现者不知道 R13 存在,报告里说「值得单开 issue」;不开,见 R13。
Task 8: complete — 提交 7eeb9f8 + 修复轮 4805b02
审查结论:Approved with minor issues。 无 Critical、无 Important。审查者用 diff -u 把 brief 的代码块
与落地文件逐字节比对(HomeViewModel.kt/AppViewModelProvider.kt 完全一致,HomeScreen.kt 只差
那一行被删掉的 import,strings.xml 恰好新增指定的 20 条),并独立确认了实现者删 import 是对的。
还逐字段核对了 Preview 的位置参数没有错位、HomeUiState 的 nullable 契约(六个指标任一为 null
都渲染「未开启」而不是 null 或 0)、state.summary 的智能转换(编译通过本身就是证明,
因为下游签名是非空)、以及测试里 backgroundScope 收集确实承重(去掉它 .value 会停在
initialValue,assertFalse(loading) 必挂)。APK 字节数与报告一致。
6 条 Minor 里,只有 1 条是本次实施产生的(HomeContentPreview 缺 KDoc,brief 本身就漏了),
其余 5 条都是计划层面的前瞻性记录,已 Park:loading 字段由构造方式决定其冗余(恒为
summary == null 的等价物)、首页没有任何错误态(M2 接真 SDK 后上游抛异常会直达
默认 handler 崩进程)、ringHealthApplication() 的强转在非 RingHealthApplication 的
ViewModelStoreOwner 下会 NPE/CCE(官方文档就是这个写法,M1 唯一路径安全)、
bloodPressure 若 M2 用 "" 表示无数据会渲染成空白卡片而非「未开启」、@StringRes 缺失。
修复轮 1(4805b02): 补上 HomeContentPreview 的一行中文 KDoc(与其余七个同类函数同风格),
并做了全 diff 的 KDoc 自查 —— 除它之外再无遗漏;同时确认属性与
@Test/setUp/tearDown 在本代码库的既有约定下本来就不写 KDoc(FormattersTest 全文件零 KDoc)。
./gradlew :app:testDebugUnitTest 12/12 通过。
Ruling R25 — Task 9 派发前的四处修正
派发前预读 brief,发现四处需要改(前两处是「新写死代码」,性质比 Task 8 那条更重):
① workout_detail_duration(时长 %1$s)与 workout_history(全部记录)是死资源。
全计划 grep:两条一个调用点都没有。时长已经作为 %1$s 拼进 workout_item_summary;
运动历史页没有标题栏,用不上「全部记录」。这个 Task 原本会新增两条永远不被引用的字符串 ——
与 R13 要消灭的「两份事实来源」是同一类问题的反面。裁定:从 Step 4 的清单中删除,并写明「不要加回来」。
② WorkoutHistoryScreen 先判空、不判 loading,会闪「暂无运动记录」。 Mock 仓库有 200ms 延迟,
records 在加载完成前就是 emptyList —— 于是页面先显示「暂无运动记录」,数据到了再跳成列表。
用户看到的是「明明有记录却说没有」。裁定:改为先判 loading(转圈),再判空,与
WorkoutContent 保持同一形状。
③ WorkoutViewModel 没有测试,而 Task 8 的 HomeViewModel 有。 两个 ViewModel 形状完全一样
(flow.map{}.stateIn(WhileSubscribed)),覆盖度不该一个测一个不测。裁定:补 WorkoutViewModelTest
(TDD 顺序,先写测试看 RED,再实现看 GREEN),因此 Task 9 的步骤整体重编号为 Step 1..9。
断言取 records.size == 5 与 records.first().type == "户外跑步" —— 后者验证的是
MockDataProvider.workouts() 的「倒序,最近的在最前」,比只数数量多一重意义。
④ "${formatDate(...)} ${formatClock(...)}" 这处插值不算违反「不硬编码文案」,明确记录以免复审误报。
两个值都由 Formatters 做本地化格式化(SimpleDateFormat + Locale.CHINA),中间的连接符是一个空格,
不含任何需要翻译的文字,也没有第二份资源与它重复 —— 满足 R13 的两条判据。裁定:保留原样。
另外自行核验了两条不是缺陷的疑似项,一并记录以免复审重复劳动:
workout_item_summary 内含「公里」而 home_distance_label 也是「公里」,看似触碰 R13 的
「是否已有第二份资源」判据,但两者语法角色不同(句内单位 vs 独立标签),译者完全可能给出不同译法,
Android 的惯例也正是把单位写进句子资源 —— 保留。
WorkoutHistoryPlaceholder 被删后 placeholder_workout_history 成为孤儿资源,
不顺手删:Task 10/12 会各自留下同样的孤儿,与其三个任务各删一次且互相看不见对方,
不如最后统一清扫。孤立字符串只占几字节。
顺带: Task 9 的 import 清单里没有 androidx.compose.foundation.lazy.item(R24 的坑只存在于 Task 8),
但 item { } 的调用点仍在(走成员分派),已确认不受影响。
Task 9: 审查结论 — Approved with minor issues(3 条,全部是计划缺陷)
审查者做了这次执行里最深的一次验证:反编译依赖来证明结论,而不是读代码猜。
javap -constants 确认 const val DETAIL_PATTERN = "detail/{$DETAIL_ARG_METRIC}" 真的在编译期折成
"detail/{metric}",三个消费方(Routes.detail()、navArgument、tabRouteFor)一致;
反编译 navigation-common 2.10.1 确认 NavDestination.route 返回的是路由模板而非填充值
(NavDestinationBuilder.build() 存的就是你传给 composable() 的那个串,没人回写),
所以 tabRouteFor 的映射是对的;还核对了 material3 1.4.0 的 lightColorScheme() 填充
onSurfaceVariant 的取值链。文件层面:两个页面与 brief 逐字节一致,RingHealthApp.kt
只动了指定的两个 hunk,WorkoutHistoryPlaceholder 全模块 grep 已无残留。
R26 — 从 Task 9 的三条 Minor 里提炼出两条全局约定
① 次要文字统一用 TextSecondary,禁用 MaterialTheme.colorScheme.onSurfaceVariant。
Task 9 的空态文字用了后者,是整个 App 里唯一一处:主题只覆写了 surface/background/surfaceVariant,
onSurfaceVariant 还是 Material 默认的 #FF49454F,而设计系统的 TextSecondary 是 #FF8E8E93 ——
同一屏里两种深浅不同的灰。这正是 R23 的同一形状(六个合规一个不合规),
而且编译器拦不住:槽位不是字面量,不算「行内样式散落」。已 grep 全计划,
该槽位只出现在 Task 9 这一处,其余任务不受影响;已写进 Global Constraints。
② 每个页面拆成「有状态外壳 + 无状态内容」并给内容加 @Preview。
Task 8 建了这个形状,Task 9 的 brief 没有,于是出现「首页有 Preview、运动页没有」的不对称;
若不管,Task 10/11/12 会各自随机选一边。而 R21 决定了本机没有任何设备,
IDE Preview 是 Task 14 人工验收之前唯一能让人看见界面的手段 —— 这不是审美问题而是验证能力问题。
已写进 Global Constraints,Task 14 的人工清单也加了两条(Preview 可渲染、次要文字灰度一致)。
—— 代价:Task 9 多一次 Screen/Content 拆分,Task 10/11/12 各多一个 Preview。
若判断有误(有人认为 Preview 不值得维护),删掉 @Preview 函数即可,不影响任何运行时行为。
关于「重按已高亮的父 Tab」: 审查者推测 popUpTo(start){saveState} + restoreState
会恢复到该 Tab 保存的子页栈(即停在子页),置信度约 85%,但需要真机确认。
我没有把它当缺陷(是 Task 7 的既有代码,本次未改动),而是转成 Task 14 人工清单里的一条实测项,
并写明「若实测与产品预期不符,另记一个小改动,不算验收失败」—— 把不确定性交给能观测的人,而不是猜。
另外两条 Observation 记录在案、不改:
Routes.detail() 仍单独写 "detail/" 前缀,F1 的手工耦合从三处降到两处而非一处;
但前缀不一致会抛 IllegalArgumentException(导航目标找不到)而非静默走错页面,
属于「响亮失败」,风险低,且 Task 12 的 RoutesTest 会把它锁住。
「开始运动」卡片没有 onClick,全计划也没有任何任务给它接回调 —— 符合 brief,但测试者可能误读为 bug,
Task 14 清单不必新增条目(该卡片本来就不在验收要点里)。
Task 9: 修复轮复审 — CLEAN(scoped re-review,031aea0..58573f4)
三条发现全部 ADDRESSED,无新增 Critical/Important 破坏,修复只动了 3 个文件(与 diff stat 一致)。
复审者按要求没有采信实现者的变异测试结论,而是自己去 MockDataProvider.kt:69 把输入钉死:
startTime = current - (index + 1) * dayMillis 随 index 严格递减,types 共 5 条、首尾差 4 天,
所以 assertTrue(first.startTime > last.startTime) 在整体倒序时必然为假 —— 鉴别力是真的。
还独立佐证了旧断言为何零鉴别力:types[0] == types[4] == "户外跑步"。
并 grep 全 app/src 确认 onSurfaceVariant 已无任何匹配。
顺带核实的两件事: WorkoutHistoryScreen 的公开外壳签名未变,RingHealthApp.kt 的零参调用点不受影响;
两处新 Preview 的样本数据具名参数与 domain/model/HealthModels.kt:48-55 完全对应。
Parked observation(不修,留待终审): 新断言只钉首尾时间戳,中间三条被任意重排(端点不动)不会被发现。 作为「替换掉一条零鉴别力断言」的修复已达标;若日后要钉全序,需改成整列表比对。 —— 代价:Mock 数据生成顺序若在中间段出错,本测试不会报警。判断为可接受:Mock 是确定性生成器, 中间段独立出错的可能性极低,而整列表比对会让测试与 Mock 实现细节耦合得更紧。
→ Task 9 完成。 031aea0 + 58573f4。
R27 — Task 10 派发前的计划修正(五条)
预读 task-10-brief.md 时发现,全部在派发前改掉:
① 步骤编号断裂。 原为 1, 1b, 4, 3, 4, 5, 6, 7, 8(「追加文案」被误标为 Step 4 且与「实现 DeviceSettingsScreen」重号)。
已在计划文件里重排为 1 … 10。
② 没有 ViewModel 测试。 Task 8 / Task 9 各有一个 ViewModel 测试,Task 10 一个都没有 —— 同一个工程里
三个 ViewModel 两种覆盖度。补了 Step 1(DeviceViewModelTest,2 个用例:输出设备状态与五个开关、
setFunctionEnabled 只改目标项)与 Step 1b(跑测试确认 RED)。
断言写 assertEquals(4, settings.count { it.enabled }) 而非只断言目标项 —— 只断言目标项无法发现
「整表被覆盖」这类回归(R25 ③ 的同一形状)。已核对 Mock:5 项里 stress/blood_pressure 初始 false,
切换 stress 后恰好 4 项开启,数值对得上。
③ 少了 @Preview(R26)。 两个页面都补上。DeviceScreen 早已是「外壳 + private DeviceContent」,
只缺 Preview;DeviceSettingsScreen 则整个没拆,已拆成公开外壳 + private DeviceSettingsContent(settings, onToggle)
(回调签名用 (String, Boolean) 而非 (DeviceFunctionSetting, Boolean):无状态层不该知道「怎么改」)。
两处 Preview 共用 internal fun previewSettings(),值抄自 MockDataProvider.defaultFunctionSettings()。
④ 8 条死字符串。 原清单 16 条里 device_title/device_charging/device_find_ring*/device_firmware_update*/
device_wear_detection* 全计划 grep 无调用点,删到 8 条并写明「不要加回来」。
⑤ Step 9 的 Expected 描述了本机做不到的事。 已按 R21 改写为「构建 + 单测」,界面部分顺延 Task 14,
并加注 FROM-CACHE 假绿的坑(Task 9 实测)。
—— 代价:Task 10 多两次测试、两个 Preview、一个 Screen/Content 拆分;若判断有误,删 Preview 与测试即可,
不动任何运行时行为。
R28 — Task 11 派发前的计划修正(三条)
同样的预读,同样在派发前改掉:
① 少了 @Preview(R26)。 ProfileScreen(modifier) 本身就是无状态的(没有 ViewModel),
所以不需要再拆外壳/内容,直接在它上面挂 Preview —— 这一点已写进 KDoc,免得审查者按 R26 的常规形状
误判为「没拆分」。
② Step 3 的 Expected 描述了本机做不到的事。 已按 R21 改写为「构建 + 单测」,并写明本任务不新增测试,
全量仍为 15 项。
③ 版本号是一份手抄副本。 profile_check_update_desc 里的 0.1.0 与 app/build.gradle.kts 的
versionName 是两处独立字面量,改版本时要一起改。不改成 BuildConfig.VERSION_NAME:AGP 8.x 默认关闭
buildConfig 生成,为一个展示串开它不划算。已写明 Task 14 更新 README 版本时一并核对。
—— 注:Task 11 的 12 条字符串全部有调用点(已逐个 grep),不是 R25/R27 那类死资源。
—— 代价:近乎为零;③ 若漏核,表现是「检查更新」里显示的版本号偏旧,不影响功能。
两个 Task 并行的风险处置
Task 9 的 scoped 复审与 Task 10 的实现同时派发(复审只读,两者文件不重叠)。
为消除「Task 10 提交时把 Task 9 修复文件卷进去」的风险,Task 10 的派发词里明确要求
不得 git add -A,只 add 本任务的文件。Task 9 复审 CLEAN、无修复轮,该风险实际未发生。
Task 10: 审查结论 — Approved(4 条 Minor,无 Critical/Important)
审查者把「状态保持」这条路径真的走通了:DeviceScreen 与 DeviceSettingsScreen 各拿到不同的
DeviceViewModel 实例(两个 NavBackStackEntry 各自的 ViewModelStore),但注入的是同一个仓库单例 ——
它去 AppContainer.kt:19 确认了 ringDeviceRepository 是容器字段而非工厂方法,
所以设置页的切换会经 MockRingDeviceRepository 的 MutableStateFlow 回流到设备页。
「返回后状态保持」因此不是靠运气,是容器设计撑住的。也确认了 Switch 是受控组件、没有 remember 影子状态。
两处必要的计划偏离都被核对为真(补 DeviceFunctionSetting import、补 Preview 的中文 KDoc)。
R30 — 采纳 Task 10 审查的两条 Minor,驳回另外两条
采纳(已改计划 + 开修复轮):
① SettingRow 的 internal 是无谓的暴露面。 消费者只有同文件的 DeviceContent,
而计划自己的注释还写着「previewSettings() 是唯一的例外」——自相矛盾。改回 private。
② DeviceViewModelTest 第一个用例断言强度不足。 它断言的三个值与 stateIn 的 initialValue 完全相同
(DeviceViewModel 的 initialValue 直接取仓库当前值),所以即使 combine 从未发射、uiState 恒为
initialValue,测试照样全绿。这是 R25 ③ 的同一形状:一条永远会通过的断言。修法是绕开 ViewModel 直接改仓库
(repository.setFunctionEnabled("stress", true))再断言状态跟着变 —— 只有 combine 真在发射才可能成立。
注意必须绕开 ViewModel:走 viewModel.setFunctionEnabled(...) 的话,即使 combine 从不发射,
状态也会因为别的路径变化,鉴别力就丢了。
驳回(记录理由,不改):
③ previewSettings() 与 MockDataProvider.defaultFunctionSettings() 逐字重复。 审查者自己也指出:
唯一的替代方案是让 feature/* import data.mock,那会破坏 CLAUDE.md 的「UI 不依赖厂商/数据实现」分层硬约束。
两害相权,重复 5 行 Preview 夹具是可接受的。这是有意接受的重复,不是疏忽;
若日后 MockDataProvider 改了数据,Preview 会与真机不一致 —— 代价明确且只影响预览观感。
④ placeholder_device_settings 变成孤儿。 R25 早已裁定三条占位字符串留到 Task 14 一次性清扫,
所以此处故意不删。审查者提得对,只是时机不对 —— 见下面 R29 的 Task 14 补丁。
R29 — Task 12 派发前的计划修正(五条)
预读时发现,全部在派发前改掉:
① 步骤编号断裂:1, 4, 3, 4, 5, 6。 已重排为 1, 1b, 1c, 2 … 7。
② 没有 ViewModel 测试。 Task 8/9/10 各一个,Task 12 一个都没有 —— 而本任务的 ViewModel 逻辑最重:
range 变化要经 flatMapLatest 换掉上游流,是全 App 唯一一处会重新订阅数据源的地方。
补了 3 个用例。第二个用例刻意断言三件事(range / 点数 / metric):只断言点数无法发现
「上游没重新订阅、只是把旧数据换了标签」;只断言 range 无法发现 flatMapLatest 没生效。
③ 没有 @Preview(R26)。 拆成公开外壳 + private MetricDetailContent(state, onBack, onSelectRange)。
标题改取 state.metric 而非外层参数 —— 无状态层只该有一个数据来源,否则可能出现「标题写心率、数据画睡眠」。
补了两个 Preview(睡眠分支 / 步数分支),因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」。
④ StatLine 里的 String.format(java.util.Locale.CHINA, "%.1f", value)。 全计划 grep 下来,
这会是整个 App 唯一一处绕过 core/common/Formatters.kt 的数值格式化 —— 而该模块存在的全部理由
就是「数值格式化集中在一处」(Task 5)。改为在 Formatters.kt 新增 formatDecimal(value: Double): String
并配一条测试。用途是详情页的「平均/最高/最低」。
—— 附带的口径决定:所有指标一律一位小数,步数因此显示成「平均 7512.3」,对连续量是对的、
对步数只是精度过剩,不算错;按指标分别定精度属 M3 的展示打磨。已记入 parked observations。
⑤ 睡眠卡片的入睡/起床时间会跨 7 天。 原写法拿 points.first() 当入睡、points.last() 当起床,
而 Mock 的睡眠历史是「每天一个点」(首点 7 天前、末点现在),于是卡片会显示「睡了 7 天」——
Task 14 的人工验收一眼就会当成 bug。改为以最后一个数据点为起床时刻、往前推 8 小时
(DEMO_SLEEP_WINDOW_MILLIS),一张卡片讲「一晚」。阶段一没有真实分期数据,这是演示值,已写明 M3 替换。
⑥ 顺手删掉一个不存在的 import 用法:计划里 import androidx.compose.foundation.layout.Column
在 Task 12 的代码里从未被使用(唯一匹配是 LazyColumn(),已删。
同时补了 Task 14 的一处真实缺口: R25 说三条占位字符串「留到最后一次性清扫」,
但计划里原先没有任何一步真的去做这件清扫。已加 Task 14 Step 1(placeholder_workout_history /
placeholder_device_settings / placeholder_metric_detail 三条,先 grep 确认无调用点再删),
并把 Task 14 后续步骤顺延为 2–7。
Task 14 的测试数也修正了: 原文写「Formatters 7 + MockDataProvider 5 + HomeViewModel 1 = 13」,
两处都错 —— FormattersTest 实际是 6 项(已 grep -c '@Test' 核对磁盘上的测试文件),
而 6+5+1=12 才是 Task 8 结束时的真实总数(与 Task 9 的 brief 自洽)。终值 21 项。
—— 教训与 R24 同类:数字也要落到文件上核对,不能凭记忆或凭早先的转述。
并行派发(第二次)
Task 10 的修复轮与 Task 11 的实现同时派发。两者文件不重叠
(feature/device/DeviceScreen.kt + DeviceViewModelTest.kt vs feature/profile/ProfileScreen.kt + strings.xml),
两边都被明确要求不得 git add -A、只 add 自己改的文件。
Task 10: 修复轮复审 — CLEAN
复审 20fe2bc..7170a49(2 文件,12 插入 / 3 删除),两条 Minor 均 ADDRESSED:
SettingRow已收窄为private(DeviceScreen.kt:129),全仓仅同文件两处命中,无跨文件调用点。DeviceViewModelTest第一条用例的鉴别力经代码级独立推导确认(复审者没有采信报告里的 变异测试声明):stateIn的initialValue在构造时就把当时的List引用冻结, 而 mock 的setFunctionEnabled走map(返回新列表),旧引用不被改写 —— 所以「combine 恒不发射」时新assertTrue必然失败,而原三条assertEquals依然全绿。 新断言确有鉴别力。
修复 diff 未引入新问题;placeholder_device_settings 孤儿、previewSettings() 逐字重复均按先前裁定保留。
Task 10 完成。
残留脆弱性(非阻塞,复审者提出,我采纳为待办): 该鉴别力依赖
MockRingDeviceRepository.setFunctionEnabled 产出新列表。若日后有人改成原地改写同一个
MutableList,initialValue 会被连带改动,这条断言就退化成自我确认。
裁定 R31:在 final review 之前不额外开修复轮去补这句 KDoc(改动价值真实但很低,
且要再走一整轮派发/复审/推送,成本远高于收益);改由 Task 14 的收尾步骤顺带补一句中文 KDoc,
写明「必须返回新列表,测试的鉴别力依赖于此」。若 Task 14 时发现该文件已因其它原因被改动,则一并补上。
—— 错了的代价:这条注释晚一点落地,期间若有人原地改写该 mock,测试会静默失去鉴别力。
R32:Task 13 的 git add -A(预读 brief 发现)
Task 13 Step 4 写的是 git add -A,与 Global Constraints 的「只 add 本任务的文件」字面冲突。
裁定:本步是那条规则的唯一豁免,且是刻意的 —— repos/ring-health-api 是 git init 出来的空仓库,
工作区里每个文件都是本任务建的,-A 卷不进别人的东西;逐条列举反而更危险(.gitignore 是隐藏文件,
漏掉它之后 build/ 之类将来会被误提交)。
加固: 在 git commit 之前插了一条 git status --porcelain,要求确认列出的正好是那三个路径 + .gitignore,
多出任何别的路径就停下来查。App 仓库不受此豁免影响。
—— 错了的代价:若这里真有意外文件被卷进提交,git status 那步会拦住,且 API 仓库是全新空仓,回退成本极低。
工作区位置记录(勘误,非缺陷)
scripts/task-brief / review-package 按 cwd 决定输出位置,于是本计划的产物落在了两处:
- App 仓
repos/ring-health-app/.superpowers/sdd/<plan>/—— brief 与 review-package 的 diff (该目录下有个.gitignore内容是*,全部被忽略) - docs 仓
docs/.superpowers/sdd/<plan>/——progress.md(本账本)与各task-N-report.md(docs 仓根.gitignore里有.superpowers/,同样被忽略)
已核实两处都没有进任何提交(git ls-files .superpowers 在两仓均为 0)。
派发时一律用绝对路径,所以分裂不影响正确性,不做搬迁(搬迁要动已被引用的路径,纯风险无收益)。
Task 13 / Task 14 的 brief 统一从 /d/cherry/docs 这个 cwd 生成,落在 docs 仓。
Task 11: 我的页 — 审查通过
88e88cf(2 文件:feature/profile/ProfileScreen.kt + res/values/strings.xml)。
Spec compliance ✅ / Code quality Approved,零 Critical、零 Important。
复审者做了独立核验而非采信报告:brief 代码块与提交文件逐字 diff、12 条 profile_* 字符串双向核对
(12 定义 ↔ 12 个不同调用点,无孤儿)、--rerun-tasks 确认 16/16 任务真正执行而非 FROM-CACHE、
提交形状(1 commit、2 文件、作者 崔鲁正)。两条 Minor 均为 brief 原文所要求(ProfileEntry 的
modifier 参数当前无供给方;5 处 Icon 用 contentDescription = null 是正确的装饰性用法),
不构成缺陷。Task 11 完成。
事故与裁定:stale brief(R33 前置)
我犯了一个真实的错:派发 Task 12 时,我把 brief 路径写成了
docs/.superpowers/sdd/<plan>/task-12-brief.md —— 那是上一轮会话生成的、R29 之前的旧版
(18.6 KB,无 formatDecimal、无 MetricDetailViewModelTest、还是 points.first()/last() 的旧睡眠逻辑),
而当前版本在 app 仓那份(29 KB)。
Task 12 的实现者自己发现了这一点(对比了两份并 diff 确认 app 仓那份与计划 Task 12 章节逐字一致),
按正确版本实现,并在报告里点名。没有造成返工。
处置: 已用 app 仓那份覆盖 docs 仓的旧 brief(覆盖后 formatDecimal 12 处、
MetricDetailViewModelTest 5 处,确认是 R29 版本)。
教训(与 R24 同类): 派发前要核验自己指给 subagent 的那个路径的内容,而不只是核验自己的记忆。
根因:task-brief 的输出位置随 cwd 变,本计划的产物因此分裂在两仓(见上方「工作区位置记录」)。
—— 错了的代价:这次为零(实现者兜住了),但换一个不较真的实现者就会照着旧版做完,
再被审查者整轮打回。
R33:Task 12 睡眠区块的恒真判空
brief 的 if (fallAsleep != null && wakeUp != null) 中,wakeUp != null 恒真
(fallAsleep 由 wakeUp 推出),Kotlin 报 Condition is always 'true' 告警
(实测位置 MetricDetailScreen.kt:174:35)。
裁定:采纳实现者的改写 if (wakeUp != null) { val fallAsleep = wakeUp - …; … }。
依据: 按情形枚举,两者语义完全等价(wakeUp 为 null → 两者皆 null → 跳过;否则 fallAsleep 必然非空 → 渲染),
且这是全仓唯一的 Kotlin 告警,消掉它使 assembleDebug 零告警。
计划文本已同步改正(否则下一个照着抄的人会重新引入这个告警),改写理由作为注释留在代码里。
—— 错了的代价:零。等价变换,且 MetricDetailScreen 的两条测试与编译都覆盖了它。
Task 14 预读发现的四处缺陷(派发前修正)
- 路径错误(会直接报错): Step 7 的核对循环写成
git -C /d/cherry/repos/$r, 且$r列表里含docs—— 而 docs 仓库在/d/cherry/docs,不是repos/docs。 更要命的是紧跟在代码块下面的注释已经指出了这一点,但代码块本身没改 —— 照抄的人会拿到fatal: not a git repository。已把路径改成逐条显式写。 git add -A: Step 7 两处都用了-A,违反 Global Constraints。已改为逐条列出本任务改过的文件 (app 仓 4 个:README.md/docs/verification-m1.md/strings.xml/MockRingDeviceRepository.kt; docs 仓 1 个计划文件,设计文档按需)。Task 13 那处-A的豁免不适用于这里(不是空仓库)。- 步骤号写错: Step 4 的说明结尾写「然后继续 Step 4」,应为 Step 5。
- 验收记录里有一句不实陈述(最重要): Step 5 的模板把
「IDE Preview:各页面的
@Preview在 Android Studio 中可渲染」列在 「自动化验证(已执行)」 标题之下 —— 但本机没有 IDE GUI,agent 根本打不开 Preview 面板,这一项从未被执行。 这正是这份模板当初被改写、要避免的那类粉饰(R21)。已改为显式的「未执行」条目, 并写明旧稿把它放在「已执行」段落等于声称验证过一件没做过的事。
Task 13: API 仓库骨架 — 实现完成
提交 fe5f686(root-commit,3 文件 / 89 插入,提交者 崔鲁正),推送成功:
首次 git push -u origin main 即过(* [new branch]),未 --force、未提示密码,
并用 git ls-remote origin 独立确认远程 HEAD 与 refs/heads/main 都指向 fe5f686,与本地一致。
远程此前为空(只有一个 main 分支),故 R32 预案里的 non-fast-forward 情形未发生。
R32 的豁免前提守住了:提交前 git status --porcelain 恰为三个 A 条目
(.gitignore / README.md / docs/api-contract.md),无多余路径。
三份文件与 brief 代码块 diff -u 全部 MATCH。repos/ring-health-app 未触碰。
待办(顺延到 Task 14): 本机 core.autocrlf=true,git add 打了 3 条
"LF will be replaced by CRLF" 警告。已核对入库 blob 与落盘文件哈希一致(仓库内是 LF),
仅未来 Windows checkout 时工作区会变 CRLF。这不是缺陷,不改配置。
Task 12: 指标详情页 — 审查结果
01689b2(8 文件 +532/−8)。Spec compliance ✅,但 Code quality: Changes requested ——
问题全在测试,生产代码、注释、字符串、路由接线都被判为正确完整。进入修复轮 1。
复审核心结论:3 条 ViewModel 用例里第 1、2 条强,第 3 条弱:
- Finding 1(Important): 第 3 条只断言
state.metric == SLEEP,而metric本来就在stateIn的initialValue里 —— 数据管线从不发射时它照样绿。这与 R30 是同一类错误, 而7170a49刚刚才在DeviceViewModelTest上修过同类问题。 更糟的是它连唯一能杀的变异(.map里写死metric = STEP)都已被第 1、2 条覆盖 —— 净鉴别力为零。 报告 §1 声称「没有任何一条断言的值与 initialValue 相等,数据管线不发射时必挂」—— 这句话对第 3 条是假的。 - Finding 2(Important):构造函数参数
metric与实际查询的数据之间没有任何断言绑定。 变异:把MetricDetailViewModel.kt:43改成observeMetricHistory(MetricType.STEP, currentRange), 三条用例全绿(points.size只取决于range;state.metric来自构造参数), 而用户点开「心率」会看到步数。路由侧由requireNotNull守住了,ViewModel→仓库这一侧完全敞开。 - Finding 3(Minor): 报告 §1 高估了测试的鉴别力(同 Finding 1)。
- Finding 4(Minor):
previewPoints(base)是base + index * 12: 步数预览 7500→7572,按value / maxValue定高后 7 根柱子全在 99%100%,视觉上是一排等高方块; 且 KDoc 写「取值贴近区间下限」对两个预览都不成立(420 在 330510 中部,7500 在 3000~12000 中部)。
已独立核验(不采信转述,R24/R29 的教训): MockDataProvider.valueRange 实测为
STEP 300012000 / HEART_RATE 5896 / SLEEP 330~510 —— 复审给的区间数字正确。
previewPoints 的实现也已读盘确认。
R34:Task 12 三条测试缺陷的处置
裁定:修复轮 1 采纳 Finding 1/2/4,一并修掉 Finding 3 那句不实陈述。
- 第 3 条用例重写为「
metric既进状态、也决定查哪组数据」:保留metric断言但显式注释它单独没有鉴别力, 加上assertEquals(7, state.points.size)(堵住「管线不发射」,同时堵住all {}在空列表上恒真的洞) 与assertTrue(state.points.all { it.value in 330.0..510.0 })(把构造参数与实际数据绑起来)。 previewPoints增加step参数:睡眠 360/24(360→504),步数 5200/1000(5200→11720), 两个预览都落在各自 Mock 区间内且柱子有可见高低差;KDoc 改为描述真实行为。- 要求实现者做变异测试自证:把
observeMetricHistory的第一参数临时改成MetricType.STEP, 确认重写后的用例会挂,再改回来确认会过,两个结果都要报。这正是上一轮缺失的证据。 - 计划文本已同步改正(否则下一个照抄的人会重新引入同一批缺陷)—— 与 R27/R29/R30 同一处置方式。 —— 错了的代价:无。测试只增鉴别力,预览只改样例数据;21 项总数不变。
Task 12: 修复轮 1 → 复审 CLEAN,任务完成
修复提交 0e17502(2 文件 +38/−11,未 amend 01689b2)。四条发现全部 ADDRESSED,未引入新问题。
复审者没有采信报告,自己重跑了变异测试(这正是上一轮缺的证据):
把 MetricDetailViewModel.kt:43 改成 observeMetricHistory(MetricType.STEP, currentRange) →
3 tests completed, 1 failed,只有新的区间断言挂掉,报文
SLEEP 详情页拿到了区间外的值:[3771.86, 3373.64, 5213.48, 8116.18, 9985.15, 9309.98, 11865.75];
第 1、2 条用例在泄漏下依然全绿 —— 这从经验上坐实了 Finding 2 的判断。
复审者随后 git checkout -- 还原并确认工作区干净。
三处值得记下的复核结论:
- 区间断言的范围比预想的更严。 复审者对着
MockDataProvider.valueRange逐条比: 除 SLEEP(330510) 与 STEP(300012000) 外,其余六条区间整体低于 330 (HEART_RATE 5896 / BLOOD_OXYGEN 9599 / STRESS 2070 / TEMPERATURE 36.136.9 / BLOOD_PRESSURE 110~130), 所以这一条能挡住泄漏到任意其它六个指标,不只是 STEP。且metricHistory对每个值都做coerceIn(min, max),区间成员性是构造保证的,不依赖随机种子抽得好。 - 空列表恒真的洞确实堵住了,且不是靠断言顺序侥幸。
all {}之前在同一次快照的直线代码里,assertEquals(7, …)先抛,all {}根本不会被求值(JUnit 顺序执行、assertEquals立即抛)。 - 修复没有削弱任何东西: 第 1、2 条用例在 diff 里只作为上下文出现、逐字未变;
用例数仍是 3(总数仍 21);两个
@Preview都在、目的未变;没有新的硬编码文案、没有data.mock泄漏进feature/*主源、没有禁用导入;生产运行逻辑一行没动(Screen 的改动只在私有预览辅助函数与预览 Composable 里)。
一处纯文字瑕疵(复审者明确不作为发现,我也认同不改): STEP 预览注释写「柱高从约 27% 递增到 100%」,
实际画出来是 3200/11000 = 29.1%(27% 是拿 3200 比区间顶 12000 得来的,而 MetricBarChart 是比 maxValue)。
注释带「约」字、无行为影响、梯度肉眼可见。不改。
Task 12 完成。
Task 14: 端到端验收与文档收尾 — 完成
9c233e5(app 仓),三个仓库均已推送且与远端同步(## main...origin/main,无 [ahead])。
App 4805b02..9c233e5,docs 5c82c38..a18523f,api 保持 fe5f686。未使用 --force。
21/21 全绿,分项与计划逐项吻合(Formatters 7 / MockDataProvider 5 / HomeViewModel 1 /
WorkoutViewModel 1 / DeviceViewModel 2 / MetricDetailViewModel 3 / RoutesTest 2);
clean assembleDebug 成功,APK 18,907,329 字节。孤儿字符串清扫命中预期的那三条
(外加 app_name —— 那是 manifest 引用的,不是孤儿),无漏接线的文案。
docs 仓无新提交是正确行为,不是遗漏: 计划文件在 Task 14 期间没有任何改动
(R29–R34 的修订早已逐条提交),git add 是货真价实的空操作。实现者没有伪造空提交 —— 正确。
brief 的核对循环真正要求的是「把 13 个已存在的未推送 docs 提交推上去」,已完成。
沿用 R21 的诚实口径: 12 行人工验收全部记为「未执行 —— 无设备」,
@Preview 渲染同样留在未执行段落,验收记录带覆盖缺口声明。
对实现者 concern 4 的独立更正(我核过,它的诊断是错的)
实现者报告「ANDROID_HOME 仍指向已删除的 SDK 路径,与 CLAUDE.md 矛盾」。实测:
| 来源 | 值 | 结论 |
|---|---|---|
用户级注册表 HKCU\Environment(真正的持久设置) |
ANDROID_HOME=D:\Android\Sdk、ANDROID_SDK_ROOT=D:\Android\Sdk |
与 CLAUDE.md 一致,正确 |
| 本会话 shell 的进程环境 | C:\Users\10314\AppData\Local\Android\Sdk |
陈旧值,来自注册表修改之前启动的父进程 |
local.properties(构建实际采用) |
sdk.dir=D:\Android\Sdk |
正确 |
结论:CLAUDE.md 没有问题,不需要改。 那只是进程环境里的陈旧副本,
凡是注册表改之前启动的进程(含本会话的 shell 及其派生的 gradle)都会看到旧值。
实际影响为零 —— AGP 以 local.properties 的 sdk.dir 为准,优先于 ANDROID_HOME;
且 19 次构建全部成功。若要彻底消除,重启终端/IDE 即可,不是代码问题。
—— 这条记下来是为了避免将来有人照着那句错误诊断去"修"一个没坏的东西。
「无设备」前提的独立复核(比实现者给的论据更强)
实现者用「AVD 指向已删除的 C 盘路径」来论证 AVD 不可用。我复核后认为那个论据本身不够硬
(AVD 的 path= 指向 C:\Users\10314\.android\avd\*.avd,而 .android 目录是存在的、
未被那次清理波及)。但结论仍然成立,且有更直接的证据:
D:\Android\Sdk 下根本没有 emulator 目录、也没有 system-images ——
只装了 build-tools / cmdline-tools / licenses / platform-tools / platforms。
没有模拟器可执行文件,也没有任何系统镜像,AVD 状态如何都跑不起来。
「无设备」前提成立,R21 的覆盖缺口声明口径正确。
全分支终审(opus,全仓 62 文件 / 19 提交)
判定:Ready to merge? With fixes. 终审做了机械核验而非目测:
feature/**→data.mock 0 处;domain/**→android.*/androidx.*/data.* 0 处;
core/designsystem 外的 Color(0x…) 0 处;com.sr.smartring/厂商引用 0 处;
65 条字符串资源双向核对全部通过;并 javap 反编译 material3-android:1.4.0 的 ScaffoldKt
来判定一个光读代码判不了的运行时问题。
明确肯定(摘): 分层边界在七个并行作者、19 次提交之后真的守住了;
七个页面架构高度一致(统一 shell + private fun XContent、统一 collectAsStateWithLifecycle、
统一 WhileSubscribed(5_000)、统一 TextSecondary);中文 KDoc 是有信息量的而非应付
(多条记录的是开发中真实发现的 bug 与被否决的备选方案);
没有发现任何幸存下来的「代码坏了测试还绿」用例 —— 之前几轮修的都真修住了。
验收记录因「诚实到自曝其短」而提高了终审对其余内容的信任度。
Critical 1(已处置,R35):M2 交接面那句「唯一需要改动的既有文件」是假的。
这是整个 M1 赖以立论的那句话(spec §102「接口不变则 UI 零改动」)。
终审逐文件核验后给出真实清单:Repositories.kt(无扫描/连接/绑定/地址/权限面)、
setFunctionEnabled 非 suspend 且返回 Unit(写开关是可能失败的 BLE 往返)、
AppContainer.kt(无参构造,SDK 适配器要 Context)、AndroidManifest.xml(权限,
计划自己 30 行后就要求改它,与「唯一一个文件」自相矛盾)、构建三件套(EventBus/Jetifier)、
DeviceScreen.kt(无任何扫描/连接 UI)。且 refreshDeviceState() 零调用点。
已按此改写计划的 M2 交接节(保留原句作为被更正的引文),并加了「读取路径接缝做对了、
设备控制与发现这条路径压根没开缝」的结论。
不做补救性改接口 —— 对着尚不存在的 SDK 改接口是投机设计。价值在于让下一个里程碑照着真实清单立项。
已采纳的代码修复(派发中,见 task-15): 详情页图表色不随指标走(违反 spec §164 色板,唯一的违约点);
空数据渲染成 0.0/0.0/0.0;血压卡片显示「未开启」却可点进有数据的详情页(自相矛盾);
stressLevelLabelRes 及其 4 条字符串是死代码(改为接线而非删除)。
关于内边距(Fix 5)的裁定 —— 我核过代码后决定"修",但如实标注未经验证:
外层 Scaffold 无 topBar ⇒ innerPadding.top = 状态栏高度,经 Modifier.padding 施加到 NavHost;
Modifier.padding 不消费 window insets,所以详情页内层 Scaffold 的 TopAppBar
会再加一次状态栏内边距 ⇒ 顶栏被压低约两个状态栏,且上方留出一条背景色带。
修法 Modifier.padding(innerPadding).consumeWindowInsets(innerPadding) 双向安全:
诊断对则消除重复内边距;诊断错则内边距本就是 0,再消费一次是 no-op。
因此收益不对称,选择修;但本机无设备无模拟器,这一条是「推理得出、未经设备验证」,
必须原样写进验收记录,并列为首次上真机的第一项待查。
Fix 6: README 让读者跑 installDebug,而本机根本没有设备/模拟器 —— 加一句说明。
R36:终审提出但我裁定"不改"的项(连同理由)
- 加 Compose UI 测试(终审的头号建议)—— 不做。 本机没有模拟器也没有真机
(SDK 里没有
emulator可执行文件、没有system-images),androidTest源集永远跑不起来。 现在加它,等于新造一批从不执行的代码 —— 正是终审自己用来批评stressLevelLabelRes的那把尺子。 改记为 M2/M3 的头号测试投资,等有设备时第一件做。 MockDataProvider里的中文硬编码("户外跑步"等)—— 不改。 那些是模型数据 (设备上报的名称、运动类型),spec 有意把它们放进领域模型,与「UI 文案」不是一回事。 真正值得记的是终审的附带发现:DeviceScreen.kt的预览夹具手抄了MockDataProvider.defaultFunctionSettings(),且没有任何东西能发现二者漂移。记为已知隐患。DeviceScreen预览夹具漂移 —— 见上,记为隐患,本次不改(改它要么让feature/*依赖data.mock, 要么把title从String改成@StringRes Int动领域模型,两者都比问题本身贵)。- ViewModel 构造两种模式(6 个用
AppViewModelProvider,详情页用自带factory)—— 不改。 带参 ViewModel 有充分理由,且理由已写进注释。记为一致性上的小瑕疵。 RingHealthApp.kt放在core/navigation却 import 全部feature.*—— 不改。 单模块内无害。RingDeviceState.charging写而无读、README 的installDebug(已并入 Fix 6)等 Minor —— 记录不改。
R37:终审对计划/文档的批评 —— 全部采纳(这一类比代码缺陷更值钱)
- 计划的 M2 交接错误 —— 已改(R35)。
- spec 与交付脱节 —— 已回填 spec 三处:工具链版本(AGP 8.11.x→8.13.1、compileSdk 35→36、
build-tools 35.0.0→36.1.0)、设备页四项被删(加删除线 + 注记)、详情页四路由收敛为
detail/{metric}、 多语言「附英文」与 M5 排期的自相矛盾。 - R27 的孤儿字符串检查口径有洞(终审指出,已采纳):R27 的检查是「grep 有没有调用点」,
而
src/test里的引用也算调用点 ——stressLevelLabelRes就是这样蒙混过关的 (唯一调用者是它自己的测试)。今后这类检查必须排除 test 源集。 stressLevelLabelRes是计划层面的缺陷(Task 5 造出函数+4 条字符串+测试,却没有任何任务负责调用它, Task 8 的示例代码把压力卡渲染成裸数字)—— 终审说得对,这是计划的错,不是执行者的错。 处置:接线(见 Fix 4),使其获得真实消费者。
终审修复轮:03a795e
六项修复全部落地,范围守住(没有加依赖、没有加测试基础设施、没有顺手重构 HomeScreen 的配色)。
新增字符串 detail_empty(暂无数据)唯一调用点 MetricDetailScreen.kt;
stress_relaxed/normal/medium/high 四条终于有了真实调用点(经压力卡的 unit)。
21/21 全绿,assembleDebug 成功,强制重编译主源与单测源后 w:/e: 零行。
我追加的一轮细化(源于实现者自己提出的 concern 2 —— 提得对):
空态原先把周/月切换 chip 一起隐藏了,于是用户在某个区间没数据时没有任何办法切到另一个区间 ——
恰好反了:数据显示为空时,切换区间正是最自然的动作,而且 chip 也是「哪个区间是空的」的唯一线索。
已要求改为:非 loading 分支始终渲染 chip,只把 chip 下方的区域(图表 + 统计摘要)换成空态。
该改动 amend 进 03a795e(未推送、且尚无任何审查针对它,amend 是干净的)。
残留(记录,不再修):
- Fix 5 的
consumeWindowInsets—— 推理得出、未经设备验证(本机无设备无模拟器)。 - 空态这条路径没有测试(拒绝新增测试基础设施),只有代码推理保证;且血压卡改为不可点后,
该路径在当前 Mock 下基本不可达(
metricHistory恒返回 7 或 30 个点),属防御性代码。
终审修复轮的定向复审:clean
复审逐条核对了 6 项修复 + 空态细化,全部 addressed,未发现新引入的缺陷:
metricTint(MetricDetailScreen.kt:267-275)覆盖全部七个MetricType,无else, 作为表达式使用 —— 将来加第八个枚举值会编译失败而不是静默落到某个默认色。 与首页各卡的 tint 逐项一致;血压→黄取自色板自身定义(spec §7 未给血压定色,属未规定而非违背)。MetricBarChart确实使用color画柱子(MetricBarChart.kt:63),不是接了个没人用的参数。onClick可空后,HealthCard只在非空时附加Modifier.clickable—— 是真正不可交互 (无涟漪、无点击语义、无无障碍 click action),不是挂了个空 lambda。全仓库只有一个调用点。- 空态字符串
detail_empty唯一调用点;全量 60 条字符串复查,无新增孤儿 (只剩app_name无R.string.*调用点,属 manifest 正常用法)。 - 空态细化确认:
RangeSelector是LazyColumn的第一个 item 且无条件渲染; 判空分支只追加一个 item 再 return,任何非 loading 状态都无法压掉 chips。 loading 分支仍在LazyColumn之前早返回,不会在 200ms 延迟窗口里闪一下空态。 - 改动面 6 个文件,无 gradle / 依赖 / 测试文件改动 —— 范围守住了。
Parked(记录在案,本轮不修)—— 均为 Ruling:
switchRange不置loading(MetricDetailViewModel.kt:41-67):切区间时旧数据留在屏上, 若旧区间为空、新区间有数据,那 ~200ms 会显示「暂无数据」。M1 中不可达 (Mock 的metricHistory恒返回 7 或 30 个点)。裁定:不修 —— 修它要动 ViewModel 的 加载语义,而这是 M3 接真实数据时必须重新设计的地方;现在改等于给一段不可达路径写代码, 正是终审用来批评stressLevelLabelRes的同一把尺子。**代价:**若 M3 的仓库会返回空区间, 这个窗口会变成可见缺陷 —— 届时按真实加载语义一并处理。averageOrZero()的isEmpty分支已成为死代码(MetricDetailScreen.kt:316): 空态提前 return 后,StatLine只可能在非空列表上走到。裁定:不删 —— 它是列表扩展函数, 语义完整(空列表取平均是 NaN),删掉反而让函数名与实现脱节。**代价:**一行永不执行的分支。
此项判决:终审 findings 已全部关闭,无 load-bearing 残留。可以进入收尾。
收尾
77de780docs: 补充终审修复轮的验证状态与 M2 交接接缝(app 仓库)- 已推送:app
9c233e5..77de780、docsa18523f..c3eb0b5;api 仓库无需改动(核对为## main...origin/main,无未推送) - 从未使用
--force。