- commit
- fe57806
- parent
- e89ee69
- author
- codex@macbookpro
- date
- 2026-03-31 19:25:53 +0800 CST
fix: harden Gemini delivery adapter fallbacks
7 files changed,
+272,
-30
+6,
-6
1@@ -17,9 +17,9 @@
2
3 除此之外,live delivery 仍保留一条受限的插件侧 DOM adapter:
4
5-5. `Claude` / `ChatGPT` 的 text-only `inject_message` / `send_message` 会由插件 content script 内的 delivery adapter 执行
6+5. `Claude` / `ChatGPT` / `Gemini` 的 text-only `inject_message` / `send_message` 会由插件 content script 内的 delivery adapter 执行
7
8-页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口;delivery adapter 也只是 thin-plugin 交付面的受限执行层,不是通用页面自动化框架。
9+页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前保留的 `/v1/browser/{claude,chatgpt,gemini}/*` 只覆盖 helper / legacy 包装与辅助回读;delivery adapter 也只是 thin-plugin 交付面的受限执行层,不是通用页面自动化框架。
10
11 ## 固定入口
12
13@@ -92,7 +92,7 @@
14 2. 再调 `GET /describe/business`
15 3. 再调 `GET /v1/browser`,确认需要的平台记录、`status` 和 `view`
16 4. 如果目标是浏览器代发,先走 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
17-5. 只有在需要 Claude legacy 包装或 Claude 辅助回读时,才调 `/v1/browser/claude/*`
18+5. 只有在需要 helper / legacy 包装或辅助回读时,才调 `/v1/browser/{claude,chatgpt,gemini}/*`
19
20 不要把这条链路当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
21
22@@ -177,7 +177,7 @@
23 - `system`
24 - `conversation_id` 允许为空;当前 replay 去重至少覆盖 `platform + assistant_message_id + raw_text`
25 - 当前 live 路径已经接到 text-only `inject / send` 闭环
26-- 但插件侧 `inject / send` 仍是 DOM adapter,当前只对 `Claude` / `ChatGPT` 做了选择器收口、page readiness 探测和有限重试
27+- 但插件侧 `inject / send` 仍是 DOM adapter,当前已对 `Claude` / `ChatGPT` / `Gemini` 做了选择器收口、page readiness 探测和有限重试;Gemini 兼容 `rich-text-field` / `rich-textarea` 的 Quill composer 变体
28 - 如果页面未 ready、关键 selector 缺失或 send 点击后没有出现确认状态,adapter 会以 `delivery.<code>` 形式明确 fail-closed,不会把“实际没发出”的场景误标成成功
29 - 超长文本当前默认只保留前 `200` 行,并在末尾追加 `超长截断`
30 - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
31@@ -190,7 +190,7 @@
32 2. `POST /v1/browser/actions`
33 3. `POST /v1/browser/request`
34 4. `POST /v1/browser/request/cancel`
35-5. `POST` / `GET /v1/browser/claude/*`(legacy 包装与 Claude 辅助读)
36+5. `POST` / `GET /v1/browser/{claude,chatgpt,gemini}/*`(helper / legacy 包装与辅助读)
37
38 这条链路的关键边界:
39
40@@ -322,7 +322,7 @@
41 4. 再调 `POST /v1/browser/request` + `responseMode=sse`
42 5. 确认浏览器回传 `stream_open` / `stream_event` / `stream_end`
43 6. 如需终止 in-flight request 或流,调 `POST /v1/browser/request/cancel`
44-7. 如需 Claude 辅助回读或旧调用方兼容,再调 `/v1/browser/claude/*`
45+7. 如需 helper 辅助回读或旧调用方兼容,再调 `/v1/browser/{claude,chatgpt,gemini}/*`
46
47 ### 4. 控制面
48
+11,
-10
1@@ -6,7 +6,7 @@
2
3 ## 当前代码基线
4
5-- 当前主分支:`main@2d65427`
6+- 当前主分支:`main@e89ee69`
7 - canonical local API:`http://100.71.210.78:4317`
8 - canonical public host:`https://conductor.makefile.so`
9 - 活跃任务文档和近期刚完成的任务文档保留在 `tasks/` 根目录;较早已完成任务归档到 [`../tasks/archive/README.md`](../tasks/archive/README.md)
10@@ -29,6 +29,8 @@
11 - `@conductor`
12 - `@system`
13 - `@browser.claude`
14+ - `@browser.chatgpt`
15+ - `@browser.gemini`
16 - delivery 当前已经是 proxy-first:
17 - conductor 记录最近观察到的业务页 route
18 - 优先派发 `browser.proxy_delivery`
19@@ -54,6 +56,7 @@
20 - `BUG-026` 已随 `98db481` 修复并归档,不再作为 open blocker 挂在 backlog
21 - `BUG-029` 已随 `625f808` 修复并归档,不再作为 open blocker 挂在 backlog
22 - `BUG-027`、`BUG-028`、`BUG-031`、`BUG-032`、`BUG-033`、`BUG-034`、`BUG-036` 已全部收口并归档,旧汇总中的 open 状态已改正
23+- `T-S048`、`T-S049` 已分别随 `7d8b4ce`、`57958a9` 合入 `main`,旧汇总仍把它们列为“下一波任务”;现统一改正
24 - `T-S055`~`T-S059` 已全部完成并合入 `main`,不再继续作为“下一波主线”
25 - `T-BUG-029` / `T-BUG-031` 的任务卡已完成,但旧汇总文档仍把它们写成 pending manual verification;现统一改为“建议补做浏览器复核”
26 - Artifact 静态服务已经完成,不再把它写成“下一阶段主线”
27@@ -62,12 +65,10 @@
28
29 **当前下一波任务:**
30
31-1. `T-S048`:Gemini 投递适配器
32-2. `T-S049`:开放 `@browser.chatgpt` / `@browser.gemini` target(依赖 `T-S048`)
33-3. `OPT-002`:executor 超时保护
34-4. `OPT-007`:renewal dispatcher 随机抖动
35-5. `OPT-008`:timed-jobs 日志改异步写入
36-6. `OPT-009`:renewal 模块重复工具函数抽取
37+1. `OPT-002`:executor 超时保护
38+2. `OPT-007`:renewal dispatcher 随机抖动
39+3. `OPT-008`:timed-jobs 日志改异步写入
40+4. `OPT-009`:renewal 模块重复工具函数抽取
41
42 并行需要持续关注:
43
44@@ -120,8 +121,8 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
45 - 现有主链已经具备完整 artifact 持久化、静态访问和插件诊断能力
46 - 已有本地对话/关联/续命任务、projector、dispatcher 和最小续命运维读接口
47 - 当前主线已无 open bug blocker
48-- 下一波回到 Gemini 投递与开放 chatgpt/gemini target 的 backlog
49-- 其余以 `OPT-*` 优化项为主
50+- `browser.chatgpt` / `browser.gemini` helper target 与 Gemini DOM delivery adapter 已在主线
51+- 当前主要以 `OPT-*` 优化项和浏览器复核为主
52
53 之前的浏览器主链继续保持:
54
55@@ -133,7 +134,7 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
56 ## 当前仍需关注
57
58 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
59-- `Gemini` 当前仍不是 `/v1/browser/request` 的正式支持面,正式 browser target 仍只有 `browser.claude`
60+- `Gemini` 当前仍不是 `/v1/browser/request` 的正式 raw relay 支持面;`@browser.gemini` 走 helper / proxy mix,仍需依赖最近观测到的真实请求上下文
61 - ChatGPT proxy send 仍依赖最近捕获的真实发送模板;如果 controller 刚重载且还没观察到真实发送,会退回同页 DOM fallback
62 - Claude 的 `organizationId` 当前仍依赖最近观测到的 org 上下文,不是完整的多页多 org 精确映射
63 - `proxy_delivery` 当前的成功语义是”请求已派发到目标页面上下文”,不是”下游 AI 已完整回复”
+4,
-4
1@@ -9,9 +9,9 @@ Firefox 插件的正式能力已经收口到四件事:
2
3 另有一条受限的 thin-plugin delivery 附属能力:
4
5-- 仅对 `Claude` / `ChatGPT` 提供 DOM adapter 方式的 text-only `inject_message` / `send_message`
6-- adapter 会把平台选择器、page readiness、重试和 fail-closed 错误收口到统一模块
7-- 这不是通用浏览器自动化框架,也不扩到 `Gemini`
8+- 对 `Claude` / `ChatGPT` / `Gemini` 提供 DOM adapter 方式的 text-only `inject_message` / `send_message`
9+- adapter 会把平台选择器、page readiness、重试和 fail-closed 错误收口到统一模块;Gemini 同时兼容 `rich-text-field` / `rich-textarea` 的 Quill composer 变体
10+- 这不是通用浏览器自动化框架
11
12 页面对话 UI、会话回读和 DOM 自动化仍然不是正式主能力。`browser.final_message` 只负责最终文本转发,不负责 BAA parser;delivery adapter 只服务 text-only delivery 的最小执行面。
13
14@@ -207,4 +207,4 @@ browser.runtime.sendMessage({
15 - `GET /v1/browser` 已经会合并活跃 bridge 和持久化记录,并返回 `view`、`status`、`account`、凭证指纹和端点元数据
16 - client 断连或长时间老化后,持久化记录仍可读取,但状态会转成 `stale` / `lost`
17 - 当前正式浏览器写接口已经收口到 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
18-- `/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
19+- `/v1/browser/{claude,chatgpt,gemini}/*` 保留给 helper / legacy 包装与辅助回读
1@@ -68,12 +68,15 @@
2 ".conversation-container",
3 "chat-window",
4 "rich-text-field",
5+ "rich-textarea",
6 ".ql-editor[contenteditable='true']",
7 "[role='textbox']"
8 ],
9 composerSelectors: [
10 "rich-text-field .ql-editor[contenteditable='true']",
11+ "rich-textarea .ql-editor[contenteditable='true']",
12 "rich-text-field div[contenteditable='true']",
13+ "rich-textarea div[contenteditable='true']",
14 "div[contenteditable='true'][aria-label*='prompt' i]",
15 "div[contenteditable='true'][aria-label*='message' i]",
16 "div[contenteditable='true'][role='textbox']",
17@@ -83,6 +86,7 @@
18 sendButtonSelectors: [
19 "button[aria-label*='Send message' i]",
20 "button[aria-label*='send' i]",
21+ "button[mattooltip*='send' i]",
22 ".send-button",
23 ".input-area-container button:not([aria-label*='microphone' i])"
24 ],
1@@ -0,0 +1,231 @@
2+const assert = require("node:assert/strict");
3+const test = require("node:test");
4+
5+const {
6+ createDeliveryRuntime,
7+ getPlatformAdapter,
8+ listPlatformAdapters
9+} = require("./delivery-adapters.js");
10+
11+function createMockElement(options = {}) {
12+ const attributes = new Map(
13+ Object.entries(options.attributes || {}).map(([key, value]) => [String(key).toLowerCase(), String(value)])
14+ );
15+
16+ return {
17+ disabled: options.disabled === true,
18+ dispatchedEvents: [],
19+ focusCalls: 0,
20+ isContentEditable: options.isContentEditable === true,
21+ tagName: String(options.tagName || "DIV").toUpperCase(),
22+ textContent: options.textContent || "",
23+ value: options.value || "",
24+ click() {
25+ options.onClick?.(this);
26+ },
27+ dispatchEvent(event) {
28+ this.dispatchedEvents.push(event);
29+ return true;
30+ },
31+ focus() {
32+ this.focusCalls += 1;
33+ },
34+ getAttribute(name) {
35+ return attributes.get(String(name || "").toLowerCase()) ?? null;
36+ },
37+ getBoundingClientRect() {
38+ if (options.visible === false) {
39+ return {
40+ height: 0,
41+ width: 0
42+ };
43+ }
44+
45+ return {
46+ height: 32,
47+ width: 120
48+ };
49+ }
50+ };
51+}
52+
53+function createHarness(options = {}) {
54+ const state = {
55+ now: 0,
56+ sending: false,
57+ url: options.url || "https://gemini.google.com/app/conv-gemini-smoke"
58+ };
59+ const selectorMap = new Map();
60+ const document = {
61+ body: options.pageReady === false ? null : {},
62+ querySelectorAll(selector) {
63+ const entry = selectorMap.get(selector);
64+
65+ if (typeof entry === "function") {
66+ return entry();
67+ }
68+
69+ return entry || [];
70+ },
71+ readyState: options.pageReady === false ? "loading" : "complete"
72+ };
73+ const runtime = createDeliveryRuntime({
74+ env: {
75+ createChangeEvent() {
76+ return {
77+ type: "change"
78+ };
79+ },
80+ createInputEvent(data) {
81+ return {
82+ data,
83+ type: "input"
84+ };
85+ },
86+ document,
87+ getComputedStyle() {
88+ return {
89+ display: "block",
90+ opacity: "1",
91+ visibility: "visible"
92+ };
93+ },
94+ getLocationHref() {
95+ return state.url;
96+ },
97+ now() {
98+ return state.now;
99+ },
100+ async sleep(ms) {
101+ state.now += Number(ms) || 0;
102+ }
103+ }
104+ });
105+
106+ return {
107+ document,
108+ registerSelector(selector, elements) {
109+ selectorMap.set(selector, elements);
110+ },
111+ runtime,
112+ state
113+ };
114+}
115+
116+test("delivery adapters register Gemini with current selector fallbacks", () => {
117+ const adapter = getPlatformAdapter("gemini");
118+
119+ assert.ok(adapter);
120+ assert.equal(adapter.platform, "gemini");
121+ assert.deepEqual(adapter.pageHosts, ["gemini.google.com"]);
122+ assert.equal(adapter.readinessSelectors.includes("rich-textarea"), true);
123+ assert.equal(adapter.composerSelectors.includes("rich-textarea .ql-editor[contenteditable='true']"), true);
124+ assert.equal(adapter.sendButtonSelectors.includes("button[mattooltip*='send' i]"), true);
125+ assert.equal(
126+ listPlatformAdapters().some((entry) => entry?.platform === "gemini"),
127+ true
128+ );
129+});
130+
131+test("Gemini inject_message works with rich-textarea Quill editor selectors", async () => {
132+ const harness = createHarness();
133+ const readyMarker = createMockElement({
134+ tagName: "rich-textarea"
135+ });
136+ const composer = createMockElement({
137+ isContentEditable: true,
138+ tagName: "div"
139+ });
140+
141+ harness.registerSelector("rich-textarea", [readyMarker]);
142+ harness.registerSelector("rich-textarea .ql-editor[contenteditable='true']", [composer]);
143+
144+ const result = await harness.runtime.handleCommand({
145+ command: "inject_message",
146+ platform: "gemini",
147+ retryAttempts: 1,
148+ text: "hello from Gemini adapter test",
149+ timeoutMs: 120
150+ });
151+
152+ assert.equal(result.ok, true);
153+ assert.equal(result.details.confirmed_by, "composer_text_match");
154+ assert.equal(composer.textContent, "hello from Gemini adapter test");
155+ assert.deepEqual(
156+ composer.dispatchedEvents.map((event) => event.type),
157+ ["input", "change"]
158+ );
159+});
160+
161+test("Gemini send_message confirms via mattooltip send button and stop icon", async () => {
162+ const harness = createHarness();
163+ const readyMarker = createMockElement({
164+ tagName: "div"
165+ });
166+ const stopIndicator = createMockElement({
167+ tagName: "mat-icon"
168+ });
169+ const composer = createMockElement({
170+ isContentEditable: true,
171+ tagName: "div",
172+ textContent: "queued gemini message"
173+ });
174+ const sendButton = createMockElement({
175+ attributes: {
176+ mattooltip: "Send message"
177+ },
178+ onClick() {
179+ composer.textContent = "";
180+ harness.state.sending = true;
181+ },
182+ tagName: "button"
183+ });
184+
185+ harness.registerSelector(".conversation-container", [readyMarker]);
186+ harness.registerSelector("rich-textarea .ql-editor[contenteditable='true']", [composer]);
187+ harness.registerSelector("button[mattooltip*='send' i]", [sendButton]);
188+ harness.registerSelector(
189+ "mat-icon[data-mat-icon-name='stop_circle']",
190+ () => (harness.state.sending ? [stopIndicator] : [])
191+ );
192+
193+ const result = await harness.runtime.handleCommand({
194+ command: "send_message",
195+ platform: "gemini",
196+ retryAttempts: 1,
197+ timeoutMs: 120
198+ });
199+
200+ assert.equal(result.ok, true);
201+ assert.equal(result.details.confirmed_by, "sending_indicator");
202+ assert.equal(harness.state.sending, true);
203+ assert.equal(composer.textContent, "");
204+});
205+
206+test("Gemini adapter fails closed on page host mismatch", async () => {
207+ const harness = createHarness({
208+ url: "https://chatgpt.com/c/conv-host-mismatch"
209+ });
210+ const readyMarker = createMockElement({
211+ tagName: "rich-textarea"
212+ });
213+ const composer = createMockElement({
214+ isContentEditable: true,
215+ tagName: "div"
216+ });
217+
218+ harness.registerSelector("rich-textarea", [readyMarker]);
219+ harness.registerSelector("rich-textarea .ql-editor[contenteditable='true']", [composer]);
220+
221+ const result = await harness.runtime.handleCommand({
222+ command: "inject_message",
223+ platform: "gemini",
224+ retryAttempts: 1,
225+ text: "should fail",
226+ timeoutMs: 120
227+ });
228+
229+ assert.equal(result.ok, false);
230+ assert.equal(result.code, "page_context_mismatch");
231+ assert.match(result.reason, /delivery\.page_context_mismatch/u);
232+});
+7,
-0
1@@ -102,3 +102,10 @@
2
3 - 选择器基于 Gemini 截至 2025 年初的已知 DOM 结构,Google 可能随时更新页面结构导致选择器失效,需在 Firefox 中实际验证并按需调整。
4 - `mat-icon[data-mat-icon-name='stop_circle']` 选择器依赖 Angular Material 实现细节,若 Gemini 前端框架迁移可能失效。
5+
6+### 2026-03-31 补充维护
7+
8+- 执行者:Codex
9+- 维护内容:基于最新 `main@e89ee69` 重新创建任务 worktree;为 Gemini adapter 补充 `rich-textarea` / `mattooltip` 选择器 fallback,并新增 `plugins/baa-firefox/delivery-adapters.test.cjs` 覆盖 Gemini 的 inject/send/host mismatch 回归。
10+- 文档同步:更新 `plugins/baa-firefox/README.md`、`docs/firefox/README.md`、`tasks/TASK_OVERVIEW.md`、`plans/STATUS_SUMMARY.md`,纠正“Gemini adapter 未完成”与“browser target 仍只有 claude”的旧口径。
11+- 额外验证:已运行 `node --test plugins/baa-firefox/delivery-adapters.test.cjs`;当前环境未做真实 Firefox + Gemini 页面手工复核。
+9,
-10
1@@ -3,7 +3,7 @@
2 ## 当前基线
3
4 - 日期:`2026-03-31`
5-- 主分支基线:`main@2d65427`
6+- 主分支基线:`main@e89ee69`
7 - canonical local API:`http://100.71.210.78:4317`
8 - canonical public host:`https://conductor.makefile.so`
9 - 当前活跃任务卡和近期刚完成的任务卡保留在本目录;较早已完成任务归档到 [`./archive/README.md`](./archive/README.md)
10@@ -25,7 +25,7 @@
11 - ChatGPT / Claude / Gemini 都已接入 `browser.final_message` raw relay
12 - ChatGPT 新对话 / 新回复的 stale final-message replay 已抑制,不再默认把新回复判成旧消息 `duplicate_message`
13 - ChatGPT SSE abort 修复和插件重载自动刷新页面都已合入 `main`
14-- conductor 已具备 BAA Phase 1:`@conductor` / `@system` / `@browser.claude`
15+- conductor 已具备 BAA Phase 1:`@conductor` / `@system` / `@browser.claude` / `@browser.chatgpt` / `@browser.gemini`
16 - delivery 当前已经是 proxy-first:
17 - conductor 记录最近观察到的业务页 route 与回写目标
18 - 优先通过 `browser.proxy_delivery` 在目标页面上下文执行真实请求
19@@ -53,6 +53,7 @@
20 - `BUG-026` 已经随 `98db481` 收口并归档,不再继续作为 open bug 跟踪
21 - `BUG-029` 已经随 `625f808` 修复并归档,不再继续作为 open bug 跟踪
22 - `BUG-027`、`BUG-028`、`BUG-031`、`BUG-032`、`BUG-033`、`BUG-034`、`BUG-036` 已全部收口并归档,旧总览中的 open 状态已改正
23+- `T-S048`、`T-S049` 已分别随 `7d8b4ce`、`57958a9` 合入 `main`,旧总览里仍写成 backlog;现统一改正
24 - `T-S055`~`T-S059` 已全部完成并合入 `main`,旧总览中的 pending 状态已改正
25 - `T-BUG-029`、`T-BUG-031` 的任务卡已是 `已完成`,但旧文档仍把它们写成 pending manual verification;现统一改为“建议补做浏览器复核”
26 - Artifact 静态服务已经完成,不再把 `T-S039`~`T-S045` 写成“当前活跃主线”
27@@ -73,19 +74,15 @@
28
29 | 项目 | 标题 | 类型 | 状态 | 说明 |
30 |---|---|---|---|---|
31-| [`T-S048`](./T-S048.md) | Gemini 投递适配器 | feat | backlog | 暂时后移,但仍是正式 backlog |
32-| [`T-S049`](./T-S049.md) | 开放 chatgpt/gemini target | feat | backlog | 依赖 `T-S048` |
33 | [`../bugs/OPT-002-executor-timeout.md`](../bugs/OPT-002-executor-timeout.md) | executor 超时保护 | opt | open | 仍建议优先收口,避免执行链路无限阻塞 |
34 | [`../plans/OPT-007-DISPATCHER-JITTER.md`](../plans/OPT-007-DISPATCHER-JITTER.md) | renewal dispatcher 随机抖动 | opt | open | 限流缓冲与节奏分散 |
35 | [`../bugs/OPT-008-timed-jobs-async-log-writes.md`](../bugs/OPT-008-timed-jobs-async-log-writes.md) | timed-jobs 异步日志写入 | opt | open | 低优先级性能卫生 |
36 | [`../bugs/OPT-009-renewal-duplicate-utility-functions.md`](../bugs/OPT-009-renewal-duplicate-utility-functions.md) | renewal 工具函数去重 | opt | open | 低优先级代码卫生 |
37
38-### 暂时后移的 backlog 与参考
39+### 已完成但保留作参考
40
41 | 任务 | 标题 | 说明 |
42 |---|---|---|
43-| [`T-S048`](./T-S048.md) | Gemini 投递适配器 | 仍保留,但暂让位于续命/定时任务主线 |
44-| [`T-S049`](./T-S049.md) | 开放 chatgpt/gemini target | 依赖 T-S048,暂后移 |
45 | [`T-S051`](./T-S051.md) | 代码文件直读映射 | 已完成,保留为后续实现参考 |
46
47 ### 已完成
48@@ -101,6 +98,8 @@
49 | T-S045 | D1 同步串联 | ✅ |
50 | T-S046 | 日志落盘 + files/read kind | ✅ |
51 | T-S047 | 跨会话接续入口 | ✅ |
52+| T-S048 | Gemini 投递适配器 | ✅ |
53+| T-S049 | 开放 chatgpt/gemini target | ✅ |
54 | T-S050 | stagit git 静态页面 | ✅ |
55 | T-S052 | D1 数据库初始化 | ✅ |
56 | T-S053 | 插件诊断日志 | ✅ |
57@@ -173,9 +172,9 @@
58
59 Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。当前主线已经没有 open bug blocker,下一步是:
60
61-- 回到 backlog `T-S048`、`T-S049`
62-- 并行推进 `OPT-002`
63-- 视情况收口 `OPT-007`、`OPT-008`、`OPT-009`
64+- 优先收口 `OPT-002`
65+- 并行推进 `OPT-007`
66+- 视情况收口 `OPT-008`、`OPT-009`
67
68 ## 现在该读什么
69