# 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\」的形式引用这里的条目。流程本来的收尾动作是删掉这份 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`(含 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 无 ``,`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 的字符串清单中补 `mmHg`。 —— 代价:多一条字符串资源。若判断有误(有人认为纯符号单位不必进资源),删掉这一条即可,无其他影响。 另外把 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)` 是 `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//` —— brief 与 review-package 的 diff (该目录下有个 `.gitignore` 内容是 `*`,全部被忽略) - **docs 仓** `docs/.superpowers/sdd//` —— `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//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 在 330~510 中部,7500 在 3000~12000 中部)。 **已独立核验(不采信转述,R24/R29 的教训):** `MockDataProvider.valueRange` 实测为 STEP 3000~12000 / HEART_RATE 58~96 / 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(330~510) 与 STEP(3000~12000) 外,其余六条区间**整体低于 330** (HEART_RATE 58~96 / BLOOD_OXYGEN 95~99 / STRESS 20~70 / TEMPERATURE 36.1~36.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