router.push("/reports")}` | `doesNotMatch(pageSource, /useRouter/)` + `doesNotMatch(pageSource, /onOpenReports/)` + 断言侧栏用 `
` 且不含 `window.location` | `onOpenReports` 从 D9 起就是死 prop(侧栏解构成 `_onOpenReports` 从未调用),真正跳转走的是整页刷新。删掉后 `router` 在 `page.tsx` 再无消费者。断言主语(站内目的地不得整页刷新)不变,且从「首页持有 router」升级成「侧栏用 Link」 |
+| 4 | `personal-report-entry` · `entry is global in the sidebar and absent from the active session header` | `match(sidebarSource, /onOpenReports/)` + `match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/)` | `match(sidebarSource, /\{ href: "\/reports", label: "我的报告"/)` + 三条 `doesNotMatch`(侧栏无 `onOpenReports`、页面无 `onOpenReports`、页面无 `useRouter`) | 同 #3。入口仍然只在侧栏一处,主语不变 |
+| 5 | `personal-report-view` · `the reader renders inside the app shell, in every phase` | `import { SecondaryShell }` + `` 计数 | `import { SecondaryHeader }` + `` 计数 | D2 把 provider + 侧栏 + inset 上移到 layout,`SecondaryShell` 拆剩顶栏并改名。「每个阶段都在外壳里、都恰好一次、没有裸 ``」一字未改 |
+| 6 | `chart-page-view` · `sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的报告` | 标签写在 `` JSX 里,用 `header.indexOf(">星盘<")` 量顺序;`leaveChat("/chart")` / `leaveChat("/ephemeris")` | 顺序改在模块常量 `NAV_PAGES` 里量,另断言这一段仍排在「新建对话」之后;三个 href/label 对逐条断言;新增 `doesNotMatch(/window\.location\.assign/)` | T1 要求三项在两种模式下逐字一致,写两遍必然再分叉,因此收进常量。主语(新建对话 → 星盘 → 星历 → 我的报告,且「我的报告」未改名)不变 |
+| 7 | `chart-page-view` · `the chart page shell is visible before the natal chart arrives` | `match(markup, /新建对话/)`(在页面组件产物里断言侧栏第一项) | `match(markup, /data-sidebar="trigger"/)`;侧栏本体改由新增的 layout 合同断言 | D2 之后侧栏不再由页面组件渲染,页面产物里本就不该有它。主语(这一页不靠一次性「返回对话」链接回去)不变,覆盖没有减少——新增的 layout 测试比原断言更强 |
+| 8 | `chart-page-view` / `ephemeris-page` 的 render 辅助 | `renderToStaticMarkup()` / `()` | 外面裹一层 `SidebarProvider`(`withSidebarProvider`) | provider 从每页各一份上移到 layout,页面组件自己不再自带,而顶栏里的 `SidebarTrigger` 仍要读它。改的是测试挂载环境,不是断言主语 |
+| 9 | `chart-view-route` / `ephemeris-page` / `stale-client-recovery` 的源码路径 | `../src/app/{chart,ephemeris,reports}/page.tsx` | `../src/app/(secondary)/{…}/page.tsx` | 路由组括号不进 URL,`/chart` 等一字未改;改的只是源码位置 |
+
+### 新增测试(+8 条,总数 3391 → 3399)
+
+- `tests/sidebar-data-cache.test.ts`(新文件,4 条):命中缓存不重拉 / 过期后重拉一次 / 写操作后拿到新标题(并逐条锁住五个写路径的调用点)/ 双账户不串 / 只存内存 / 401 清空。
+- `tests/sidebar-state.test.ts` +2:收起后重挂仍收起(含无存储、脏值、存储不可用三种降级);移动端不读不写且 `null` 存储不抛。
+- `tests/sidebar-contract.test.ts` +1:`the same component renders read-only when / is not the one mounting it`——`controls?` 可选、只读行带 `.session-main`、只读行无 `session-menu-trigger`、`.session-row[data-readonly="true"]` 不留 44px 列、页脚是 `.profile-trigger` 链接。
+- `tests/chart-page-view.test.tsx` +1:`the (secondary) layout mounts one read-only sidebar for all four routes`——`` 恰好一次、不传 `controls`、数据 hook 只发两个 GET 且不含任何写方法。
+
+## 五、让步顺序
+
+**触发了第 1 条**(T5 折叠持久化 SSR 首帧对不齐),没有触发第 2、3 条。
+
+- 选的是 **localStorage**,不是 cookie。理由是硬约束而不是偏好:`/`、`/chart`、`/ephemeris` 三条路由都是 `○ Static`,在服务端读 cookie 会让它们一起掉出静态渲染,直接违反红线 3。cookie 若只在客户端读,则不比 localStorage 早一帧,没有任何收益。
+- 因此读取只能发生在挂载后——具体是 `SidebarProvider` 里 `ready` 那个 effect,也就是断点默认值**原本就生效**的同一帧。对客户端跳转(`/` ↔ 次级页)没有任何闪动,因为 provider 要么不重挂、要么重挂时 `localStorage` 已可读。
+- 剩下的缺口只有一种情形:桌面用户把侧栏收起来之后**整页加载**(F5 或直接输入网址)。首帧是展开,effect 之后收起。这一下在基线上不存在,因为基线根本不记状态。已写进 BUG-746 的「已知缺口」段与真机清单 E-4,请产品实测后判断是否值得再开一单(真正的修法是服务端读 cookie,代价是 `/` 掉出静态)。
+- 同轮考虑并否决的两个绕法:`useState` 初始化里同步读 localStorage(客户端首帧与 SSR HTML 不一致,React 19 会报 hydration mismatch,`suppressHydrationWarning` 又会让 React 跳过属性修补);根 layout 里塞内联脚本改 `` 属性(要把 `.chat-app` 的栅格改成跟 `` 走,动的面比本单大得多)。
+
+## 六、次级页体积增加的原因
+
+红线 4 只约束 `/` 的首屏 gzip(+0.94%,通过),次级页要求「列出」。它们涨了 8.8% / 13.2%,原因是 D1 的直接代价:次级页原先加载的是一个精简的只读组件,现在加载的是**整个** `AppSidebar` —— 里面包含 `@base-ui/react/menu`、`ThemePreferenceMenu`、`UserAvatar` 和带菜单的 `SidebarSessionRow`,即便只读模式一个都不渲染,模块仍然在同一个 chunk 里被打进去。
+
+这是「一个组件」与「次级页更小」之间的取舍,产品在 D1 已经拍板要前者。若日后要把这部分拿回来,可行的做法是把账户菜单弹层拆成独立组件、只在 `controls` 存在时动态加载;本单没有做,因为它会在 `/` 的页脚引入一个加载态,撞红线 7(揭幕后不得出现 spinner / 骨架)。建议作为独立一单评估。
+
+## 七、红线逐条
+
+| 红线 | 结论 |
+| --- | --- |
+| 1 · `tsc` 0 错、`lint` 0 error | ✅ 0 / 0;warning 118 → 115 |
+| 2 · 测试总数不降;`sidebar-contract`(43) 与 `sidebar-state`(5) 不删条 | ✅ 3391 → 3399;`sidebar-contract` 43 → **44**(只改写、只新增,未删);`sidebar-state` 5 → **7** |
+| 3 · `/` 仍 `○ Static`;其余四路由标记不变;路由组不改 URL | ✅ 五个标记逐个比对一致(见 §一);四个 URL 未变 |
+| 4 · `/` 首屏 gzip ±2% | ✅ CSS −0.11%,JS +0.94%;次级页已列出并说明 |
+| 5 · `page.tsx` 不增长;`Home()` 的 `useState` / `useRef` 不增长 | ✅ 行数 1838 → **1837**(净删);`useState` **36 → 36**(cap 36)、`useRef` **37 → 37**(cap 37);`home-shell-growth-contract` 全绿。对 `page.tsx` 的改动只有两处:`AppSidebar` 的 props 形状(散 props 收进 `controls`)、删掉 `useRouter` 与死 prop `onOpenReports` |
+| 6 · 次级页侧栏不发写接口;只读模式不 import `page.tsx` 的 hook | ✅ `use-sidebar-data.ts` 只有两个 GET,合同测试禁止 `method: "POST\|PATCH\|PUT\|DELETE"`;layout 不传 `controls`;`app-sidebar.tsx` 不 import 任何 `page.tsx` 的 hook(合同测试仍禁止 `fetch(` / `/api/`) |
+| 7 · 揭幕后不得出现 spinner / 骨架 | ✅ 缓存未命中时仍是既有静态文案 `sidebar-empty`(「对话列表读取中」/「暂无对话…」/「登录后可以看到你的对话」);合同测试禁止 hook 里出现 `skeleton\|Spinner` |
+| 8 · 不改 `useConversationScrollAnchor` / `ChatComposer` / BUG-698 的 `@supports` / 报告盘面 grid | ✅ 四者一字未动 |
+| 9 · 同提交更新 `DESIGN.md` | ✅ 「Secondary page shell」整节重写成「一个组件、两种模式 + 只读少哪三样」,删掉 `.nav-rail-*` 描述;「Sidebar shell」的 **State** 一行按 T5 重写;报告打印一段的「nav rail」改「sidebar」 |
+| 10 · 不升依赖、不顺手修 warning | ✅ `package.json` / `package-lock.json` 未动;warning 的减少全部来自删文件 |
+
+## 八、环境缺口(不得写成通过)
+
+| 项 | 缺什么 | 替代证据 |
+| --- | --- | --- |
+| Network 面板确认跳转无 document 请求 | 无 Chrome、无登录态 | 源码合同:侧栏禁止 `window.location`,三项是 ``;真机清单 §A |
+| 跨页无 `/api/sessions` / `/api/account` | 同上 | layout 合同锁 `` 恰好一次 + `useSidebarData()` 只在 layout 调用;缓存单测覆盖「命中不发 fetch」。真机清单 §B |
+| 两页侧栏 DOM outerHTML 逐节点 diff | 同上(且任务书要求脱敏真实会话标题,本地无数据) | 同一个组件、同一份 `SidebarSessionRow`、同一份 CSS,差异面由 `sidebar-contract` 的只读合同锁死;真机清单 §D 给了可照做的 `copy(...)` 步骤 |
+| 重命名后进 `/chart` 立刻可见 | 同上 | 单测锁五个写路径都调 `invalidateSidebarCache()`;真机清单 §C |
+| 折叠状态跨页 / 整页刷新是否闪 | 同上 | 纯函数单测 + provider 源码断言;真机清单 §E(含第 4 条专门记录闪动) |
+| `/reports/[reportId]` 打印仍隐藏侧栏 | 无 Chrome | `REPORT_SHELL_PRINT_CSS` 命中的五个选择器(`.chat-app`、`.chat-panel`、`[data-slot='sidebar-inset']`、`[data-slot='sidebar']`、`.chat-header`)在 layout 化之后一个不少、层级关系也没变——原先 `SecondaryShell` 渲染的正是这同一串,只是位置从组件内挪到了 layout;`printing a report inside the shell drops the chrome and its height lock` 合同测试仍绿。真机清单 §F |
+| staging 部署核对 | 未推送(按分工只交本地分支,验收由主会话做) | 无 |
+
+## 九、Python 侧
+
+本轮零 Python 改动,未动 `scripts/` 与 `tests/`(Python)。核对过没有任何 Python 测试或脚本枚举 `frontend/src/app` 的路由集合(`grep -rn "src/app" --include=*.py` 只命中 `page.tsx`、`globals.css` 与几个 `api/*/route.ts` 的定点路径,均未移动),因此路由组不会打红 Python 合同(BUG-132 / BUG-736 那一类复发面已核对)。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index b42d17cd..ffca3298 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -252,7 +252,7 @@
| `TASK-api-server-backdoor-close-20260916.md` | — | **取代 decomposition 单的阶段 1** · **第二版(2026-09-16 改写)**:第一版的「8 方法 / 314 行」**错了一个数量级**——闭包只跟了 `self._x()`,漏掉 `_compute_consultation_workflow` 那 6 行委托转给模块级 `execute_consultation_workflow(self, ...)` 的一跳。执行方重算实测 **115 方法 / 4,104 行(占全类 51% 方法),但碰 HTTP 上下文的仍是 0 个**。产品在 A/B/C 中**选定 B(mixin 抽取)**:方法体原样搬进 `ConsultationComputeMixin`,`JyotishAPIHandler(BaseHTTPRequestHandler, Mixin)` 靠 MRO 解析,离线调用方直接实例化 mixin——四个伪造点全关。**产品同时授权:§4 那条「不得搬 ≥150 行业务方法」按本意解释(本意是防逐行改写的 C 案),mixin 放行。** 必须先过 **spike 闸门**:搬完后 `test_api_server_security.py` 一字不改直接跑,绿则继续、红则退回 A(只搬 7 方法/300 行、关 2 个伪造点)。真风险是循环 import——闭包引用同文件 44 个模块级函数 + 21 个常量。`__new__` 归零只针对 scripts 侧 4→0(tests 侧 29 处含不许改的 security 测试,保持不增长)。阶段 3 仍不做。不占 BUG 号 | **已执行(spike 红 → 退回 A)** | spike 实测:整体 mixin 化后 `test_api_server_security.py` **1 failed / 128 passed**——`test_chart_async_job_executes_in_background` 挂在 `monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', ...)`:调用方法随 mixin 搬走后从新模块 globals 解析,补丁落在旧模块上不生效(已证明把同一 fake 打到 mixin 模块即恢复原行为,非搬坏)。循环 import 不是障碍(移动集不引用 `JyotishAPIHandler`)。按 §6.3 退回 A:`scripts/offline_compute_mixins.py` 收 7 方法 / 300 行 + 3 助手 + `BadRequest`,关掉 `consultation_workflow_service:27` 与 `local_accuracy_report:141` 两处伪造,scripts 侧 `__new__` **4 → 2**;类方法 225 → 218、行数 11,291 → 10,924;security 测试一字未改 **129 passed**。剩余 2 处伪造都在咨询工作流链上,需另案解决 monkeypatch 落点问题 |
| `RECONCILE-20260916.md` | — | **对账清单(不是任务书)**:状态板与现实脱节(09-15~16 那 10 份早已合入却仍写「待领取/待验收」,本轮已修正),所以另外 7 份 09-10~14 的单**不能拿 README 当证据**——它们没有 `PROGRESS-*.md`,但引用的 BUG 号都是 `resolved`。要么被别的单顺带修了没留记录,要么压根没做。每行回一个「做了/没做」即可。另附三条我已查到确定没做的证据、两条状态未闭环的、以及 BLK-001 仍红(2026-09-16 在 `4f643aa0` 复跑确认) | 待产品负责人 / 执行方回填 | — |
| `TASK-rectification-precision-gate-guided-collect-20260916.md` | `PROGRESS-rectification-precision-gate-guided-collect-20260916.md` | **出卡加精度门槛 + 引导式补经历**:真机 30 分钟窗给 5 件事 / 3 领域就出卡,区间 20 分钟、5 候选并列 26/26/20%,卡下只有自由文本邀请。产品拍板 D1 出卡门槛「宽度 ≤10 分钟且前两名差 >3 个百分点且不并列」;D2 用户说没有了仍按现行规则出卡(保留永远给结果);D3 门槛未达改为系统点名逐题问 + 类型芯片 + 年/月选择器录入,删自由文本邀请;D4 跳过的线换问法再问一次(拒绝不重问,改 BUG-687 口径);D5 时间点题答「没发生」不关领域、存在性题问整个领域、七条线一张表不许按领域写死。引擎新增 `guided_collect_windows`(边界不设 45 天闸门、含小运),不改打分与 lead。Skill 10.0.27。附 T6 两条核实:B/C/D 回执文案对调、性格题后范围回弹 8 分钟。BUG-740~743 | **已验收:未通过(带修复单)** | `cfb41daf`;tsc/lint/Static/gzip(+0.44%) 通过,D1~D5 单测通过;但 `npm test` 新增 16 红(校正既有断言未跟上)+ memoization golden 因 receipt 新键红(门禁级,staging 仍部署 317e9f18);引导窗口领域轮询分配、回放未按真值注入、录入卡走模型轮 → `TASK-rectification-precision-gate-guided-collect-fix-20260916.md` |
-| `TASK-sidebar-unify-20260916.md` | — | **侧栏统一成一个组件 + 次级页共享外壳 + 跳转不整页刷新(纯前端)**:`/` 用 `AppSidebar`、四个次级页用另写的 `AppNavRail`,两套标记结构共用一份 CSS,会话行少 `.session-main` 一层(无内边距 / 44px 高 / 当前项标记、留 44px 空列)、页脚 56px 菜单 vs 44px 文字;首页进次级页是 `window.location.assign` 整页刷新,次级页各自在组件里挂 `SecondaryShell`、无共享 layout 无缓存,每次跳转重拉 `/api/sessions` + `/api/account`;折叠状态不持久。产品拍板 D1 只留一个 `AppSidebar`(操作回调可选 = 只读模式,D9「不带写操作」维持)、D2 路由组 `app/(secondary)/` layout 承载外壳、D3 改 ``、D4 模块级 60 s 缓存 + 首页写操作失效、D5 折叠状态存 cookie、D6 回首页慢的启动链另开一单。实测:接口 TTFB 0.63~0.90 s,回 `/` 启动链串行 ≥4 次往返 + 揭幕闸门 4 s。BUG-744~746 | 待领取 | — |
+| `TASK-sidebar-unify-20260916.md` | `PROGRESS-sidebar-unify-20260916.md` | **侧栏统一成一个组件 + 次级页共享外壳 + 跳转不整页刷新(纯前端)**:`/` 用 `AppSidebar`、四个次级页用另写的 `AppNavRail`,两套标记结构共用一份 CSS,会话行少 `.session-main` 一层(无内边距 / 44px 高 / 当前项标记、留 44px 空列)、页脚 56px 菜单 vs 44px 文字;首页进次级页是 `window.location.assign` 整页刷新,次级页各自在组件里挂 `SecondaryShell`、无共享 layout 无缓存,每次跳转重拉 `/api/sessions` + `/api/account`;折叠状态不持久。产品拍板 D1 只留一个 `AppSidebar`(操作回调可选 = 只读模式,D9「不带写操作」维持)、D2 路由组 `app/(secondary)/` layout 承载外壳、D3 改 ``、D4 模块级 60 s 缓存 + 首页写操作失效、D5 折叠状态存 cookie、D6 回首页慢的启动链另开一单。实测:接口 TTFB 0.63~0.90 s,回 `/` 启动链串行 ≥4 次往返 + 揭幕闸门 4 s。BUG-744~746 | 待验收 | `codex/sidebar-unify-20260916` |
| `TASK-rectification-precision-gate-guided-collect-fix-20260916.md` | — | **验收修复单**:F1 `decision_receipt` 新增 `guided_collect_windows` 让 `test_rectification_engine_memoization` golden 红(在 CORE_PYTEST_TARGETS,staging 未部署 cfb41daf);F2 16 条既有校正断言红未按三栏改;F3 `mayDeliverOnPrecision` 缺省即放行、短路 BUG-654 且 `guidedCollectExhausted` 不含 refresh;F4 无领域轨道窗口轮询贴领域、一窗只问一领域;F5 离线回放注入 month_lo 非真值方向、0/20 不作数;F6 录入卡合成「或」列表句走模型轮;F7 page.tsx +1、手造 fixture。D6 产品已拍板:门槛只是必要条件,仍问完线再出。BUG 段 744 起 | 待领取 | 与主单同文件;开工基线 3391 / 47 |
## 命名与归档
diff --git a/docs/testing/sidebar-unify-20260916.md b/docs/testing/sidebar-unify-20260916.md
new file mode 100644
index 00000000..10444460
--- /dev/null
+++ b/docs/testing/sidebar-unify-20260916.md
@@ -0,0 +1,77 @@
+# 真机清单 · 侧栏统一 + 次级页共享外壳 + 跳转不整页刷新(2026-09-16)
+
+对应任务书 `TASK-sidebar-unify-20260916.md`、进度记录 `PROGRESS-sidebar-unify-20260916.md`、`BUG-744` / `BUG-745` / `BUG-746`。
+
+下面每一条自动化做不了:都需要**浏览器 + 已登录账号**。执行环境无 Chrome、无登录态,因此这些是环境缺口,不是「已通过」。
+
+准备:桌面 Chrome,宽度 ≥ 1024px,登录后至少有 3 段对话(其中 1 段已收藏)。开 DevTools → Network,勾上 `Preserve log`,筛选框留空。
+
+## A · 跳转不再整页刷新(BUG-745 ①)
+
+1. 停在 `/`,Network 面板 Clear。
+2. 点侧栏「星盘」。
+ - **期望**:地址栏变 `/chart`;Network 里 **没有** Type = `document` 的请求(只有 `fetch/xhr` 和 `script`)。
+ - **不期望**:整页白一下、Network 顶部出现一条 `chart` 的 document 请求。
+3. 同样点「星历」「我的报告」各一次,各自确认无 document 请求。
+4. 点侧栏「新建对话」回 `/`,确认同样没有 document 请求。
+
+## B · 四个页面之间不重拉会话列表(BUG-745 ②③)
+
+1. 停在 `/chart`,Network Clear,筛选框输入 `api/`。
+2. 依次点「星历」→「我的报告」→ 打开任意一份已完成报告。
+ - **期望**:整个过程里 **没有** `/api/sessions` 与 `/api/account` 请求。
+ - 页面自己的数据请求(`/api/ephemeris`、`/api/reports` 等)照常,属正常。
+3. 原地等 70 秒以上,再点一次「星盘」。
+ - **期望**:这时才出现一次 `/api/sessions` + 一次 `/api/account`(60 秒缓存过期后的后台刷新),而且**刷新期间侧栏的会话列表一直在**,不闪空、不出骨架。
+
+## C · 写操作后立刻可见(BUG-745 ③)
+
+1. 回 `/`,把任意一段对话重命名成一个好认的名字(例如「重命名验证 A」)。
+2. 立刻点「星盘」。
+ - **期望**:`/chart` 左侧栏里这一行已经是新名字。
+3. 回 `/` 新建一段对话,立刻进「我的报告」。
+ - **期望**:新对话在侧栏最上面。
+4. 回 `/` 归档一段对话,立刻进「星历」。
+ - **期望**:它已从侧栏消失。
+
+## D · 两页侧栏逐节点一致(BUG-744)
+
+1. 在 `/` 打开 DevTools Console,执行:
+ `copy(document.querySelector('[data-sidebar="sidebar"]').outerHTML)`,粘进一个文本文件。
+2. 在 `/chart` 执行同一句,粘进第二个文件。
+3. 把两份里的会话标题替换成同样的占位(脱敏后再对比),然后 diff。
+ - **期望**:差异只有三处——每行的菜单按钮 `.session-menu-trigger`、页脚的 `.chevron`、页脚外面那层账户菜单触发器(`/` 是 `