Files
ring-health-docs/superpowers/plans/2026-09-10-ring-health-app-m0-m1-ledger.md
T

96 KiB
Raw Blame History

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:24 Text("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 不重装(用户选择;真要用再装)。

防复发规则(后续任务一律遵守):

  1. 不再清理 C 盘。用户已明确"不用腾 C 盘空间了"。
  2. 任何删除操作前必须先 Get-Item 确认目标,并优先"先复制验证、再删源"。
  3. 绝不使用会把大目录卷入的通配路径删除。

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 显示 gradlew old 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) 是空操作;M6 StatBar 里 .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):

  1. formatCalories(kcal) 只返回千分位数字;「千卡」由调用点用资源拼接。 依据:R.string.home_calories_label 已是「千卡」,硬编码即 R3 所说的两份事实来源。 R.string.workout_detail_calories 由 消耗 %1$s 改为 消耗 %1$s 千卡(唯一调用点,2742 行)。
  2. stressLevelLabel(stress): String → @StringRes stressLevelLabelRes(stress): Int, 4 条中文描述进 strings.xml。依据:与 R3 对指标名的处理保持同一模式。
  3. 豁免:formatSleepDuration(7小时32分)与 formatDuration(1小时05分)保留。 依据:数字与单位黏合成单一复合值,设计系统没有独立单位槽位可放,拆开会把同一段 when 复制进每个调用点;且「小时/分」在 strings.xml 里没有第二份。豁免已写进 Global Constraints, 并写明判据(该文字是否已有第二份资源 + 设计系统是否已提供独立槽位),避免后续审查重复误报。 代价:Task 5 两处测试断言改写;strings.xml 新增 4 条;Task 8 首页 calories 由 .toString() 改走 formatCalories()(顺带消掉 Task 8 代码块里原本未被使用的 formatCalories import)。 若判断有误,回退范围约 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:40 setFunctionEnabled 非 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 预读发现的四处缺陷(派发前修正)

  1. 路径错误(会直接报错): Step 7 的核对循环写成 git -C /d/cherry/repos/$r, 且 $r 列表里含 docs —— 而 docs 仓库在 /d/cherry/docs,不是 repos/docs。 更要命的是紧跟在代码块下面的注释已经指出了这一点,但代码块本身没改 —— 照抄的人会拿到 fatal: not a git repository。已把路径改成逐条显式写。
  2. 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 的豁免不适用于这里(不是空仓库)。
  3. 步骤号写错: Step 4 的说明结尾写「然后继续 Step 4」,应为 Step 5。
  4. 验收记录里有一句不实陈述(最重要): 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:终审对计划/文档的批评 —— 全部采纳(这一类比代码缺陷更值钱)

  1. 计划的 M2 交接错误 —— 已改(R35)。
  2. spec 与交付脱节 —— 已回填 spec 三处:工具链版本(AGP 8.11.x→8.13.1、compileSdk 35→36、 build-tools 35.0.0→36.1.0)、设备页四项被删(加删除线 + 注记)、详情页四路由收敛为 detail/{metric}、 多语言「附英文」与 M5 排期的自相矛盾。
  3. R27 的孤儿字符串检查口径有洞(终审指出,已采纳):R27 的检查是「grep 有没有调用点」, 而 src/test 里的引用也算调用点 —— stressLevelLabelRes 就是这样蒙混过关的 (唯一调用者是它自己的测试)。今后这类检查必须排除 test 源集。
  4. 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 残留。可以进入收尾。

收尾

  • 77de780 docs: 补充终审修复轮的验证状态与 M2 交接接缝(app 仓库)
  • 已推送:app 9c233e5..77de780、docs a18523f..c3eb0b5;api 仓库无需改动(核对为 ## main...origin/main,无未推送)
  • 从未使用 --force。

Task 15: complete