From b2a9d8f617ca02c2c740150e10f2428010b68ce4 Mon Sep 17 00:00:00 2001
From: 1 <wentingyear@gmail.com>
Date: Fri, 12 Jun 2026 10:45:32 +0800
Subject: [PATCH] chore: update workspace docs and restore scripts

---
 scripts/restore-sessions/restore-management-observer.bat    |   66 +
 scripts/restore-sessions/README.md                          |   42 +
 scripts/restore-sessions/00-start-codex-app-server-4570.bat |   47 +
 scripts/restore-sessions/open-management-admin.bat          |   66 +
 .gitignore                                                  |    7 
 scripts/kline_front_incremental_sync.py                     | 1010 +++++++++++++++++++++++++
 common/ai-workplace/AI工作空间创建指南.md                           |   17 
 data/HTTP与MySQL使用说明.md                                      |  312 +++++++
 data/K线同步到本地数据库全景说明.md                                      |  659 ++++++++++++++++
 scripts/restore-sessions/open-management-observer.bat       |   66 +
 scripts/restore-sessions/restore-management-admin.bat       |   66 +
 全局规范.md                                                     |   25 
 12 files changed, 2,376 insertions(+), 7 deletions(-)

diff --git a/.gitignore b/.gitignore
index abef3d9..256c59f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,8 +2,15 @@
 mbx.yaml
 mbx.yaml.bak
 /.mbx/
+tmp/
+case_analysis.*
+scripts/**/__pycache__/
+scripts/**/*.pyc
+data/*.local.md
 管理端上下文.md
 MB-X错误日志.md
+MB-X错误日志.md
+MB-X*.md
 /manage-doc/
 /ai-*/
 /work/
diff --git "a/common/ai-workplace/AI\345\267\245\344\275\234\347\251\272\351\227\264\345\210\233\345\273\272\346\214\207\345\215\227.md" "b/common/ai-workplace/AI\345\267\245\344\275\234\347\251\272\351\227\264\345\210\233\345\273\272\346\214\207\345\215\227.md"
index e7df328..d24261d 100644
--- "a/common/ai-workplace/AI\345\267\245\344\275\234\347\251\272\351\227\264\345\210\233\345\273\272\346\214\207\345\215\227.md"
+++ "b/common/ai-workplace/AI\345\267\245\344\275\234\347\251\272\351\227\264\345\210\233\345\273\272\346\214\207\345\215\227.md"
@@ -223,7 +223,8 @@
 9. 审核边界:如果该 AI 是审核员,要写清审核体系、审计报告入口和不能直接改被审计产物。
 10. 审核规范维护边界:如果该 AI 是审核员,要写清可维护的本地审核 / 审计规范入口,以及不得默认修改的被审体系执行规范入口。
 11. 技能使用入口:写清允许使用的技能、显式声明方式、状态变更记录和失败处理口径。
-12. 禁止事项和越权边界。
+12. Codex 会话上下文保护:写清主体内容落文档、窗口只展示摘要和证据入口、大输出不得直接回显、上下文耗尽不得擅自新建 thread。
+13. 禁止事项和越权边界。
 
 `工作说明.md` 不能替代 `实验规范.md`、`编码规范.md`、`开发审计规范.md`、`项目规范.md` 等体系文档。体系流程变化时,以对应体系文档为准,工作说明只做入口索引。
 审核员角色的“必读文档入口”必须同时列出被审体系规范、审计规范 / 审计报告入口、被审对象账本入口。
@@ -300,7 +301,17 @@
 3. 角色间交接、审核、确认、退回、升级时,必须进入正式消息链或正式账本,不得只在聊天窗口声明。
 4. 所需技能、目标角色、审核入口或权限不明确时,先反馈项目管理员,不得自行扩大权限。
 
-## 8. 审核边界
+## 8. Codex 会话上下文保护
+
+1. 主体内容落文档,窗口只展示摘要和证据入口。
+2. 不直接使用 `Get-Content -Raw` 将大文件完整输出到窗口。
+3. `rg`、`Select-String`、日志检索、代码检索等操作必须限制输出行数;全量结果写入 `tmp/`、readout 文件、证据包、审计附件或正式结果文档。
+4. 不对大目录执行无上限递归列表并直接回显;目录扫描结果写入文件,只展示摘要、数量、关键路径和异常项。
+5. 图片证据优先记录图片路径、manifest、缩略图或抽样结果;避免批量 `view_image` 把大图片载荷写入会话历史。
+6. MB-X 消息和角色窗口只展示摘要、关键行、文件路径、证据入口、结论和期望动作,不复制大段正文。
+7. 处理 context window full 时,不得未经确认直接创建新 thread;应先备份、摘要、裁剪、记录审计并尝试原 thread 恢复。
+
+## 9. 审核边界
 
 如果本 AI 是审核员:
 
@@ -310,7 +321,7 @@
 4. 不直接改被审计主产物,不默认改被审体系执行规范。
 5. 自审必须标注“自审”,并列出可复核证据。
 
-## 9. 禁止事项
+## 10. 禁止事项
 
 1. 不得把工作空间当正式产物目录。
 2. 不得越权写未分配体系目录。
diff --git "a/data/HTTP\344\270\216MySQL\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/data/HTTP\344\270\216MySQL\344\275\277\347\224\250\350\257\264\346\230\216.md"
new file mode 100644
index 0000000..763d342
--- /dev/null
+++ "b/data/HTTP\344\270\216MySQL\344\275\277\347\224\250\350\257\264\346\230\216.md"
@@ -0,0 +1,312 @@
+# 前复权 K 线补数服务 HTTP 与 MySQL 使用说明
+
+- 适用对象:内部同事通过 HTTP 触发补数任务,并通过 MySQL 读取前复权 K 线数据。
+- HTTP 外部地址:`http://317w7246e5.vicp.fun:12181`
+- MySQL 外部地址:`317w7246e5.vicp.fun:50176`
+- MySQL 数据库:`trading_xuntou`
+- MySQL 用户:`root`
+- MySQL 密码:请向服务负责人获取,正式文档不写明文密码。
+
+## 1. 使用原则
+
+1. HTTP 接口只用于提交任务、查询任务状态、查询覆盖率、触发修复和触发 60 分钟线派生。
+2. 行情数据读取以 MySQL 为主,不建议通过 HTTP 查询大批量行情。
+3. 当前 K 线主表均为前复权口径,优先使用 `*_front` 表。
+4. 分钟线任务数据量很大,禁止随意提交全 A 多年分钟线任务。
+5. 大范围分钟线补数建议按月份执行,参数使用 `download_policy=missing`。
+6. 轮询任务状态建议间隔 `10-30` 秒,不要高频请求。
+
+## 2. HTTP 接口清单
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `GET` | `/health` | 检查服务是否在线 |
+| `POST` | `/jobs` | 提交补数或校验任务 |
+| `GET` | `/jobs` | 查询最近任务列表 |
+| `GET` | `/jobs/{job_id}` | 查询单个任务状态 |
+| `GET` | `/coverage` | 查询覆盖统计 |
+| `POST` | `/repair` | 提交缺口修复任务 |
+| `POST` | `/derive_60m` | 从本地 `1m` 派生 `60m` |
+
+## 3. 常用 HTTP 示例
+
+检查服务:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/health"
+```
+
+提交单股日线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1d"],
+    "start_date": "2026-05-01",
+    "end_date": "2026-05-26",
+    "symbols": ["600519.SH"],
+    "batch_size": 1,
+    "download_policy": "missing",
+    "reason": "manual_single_symbol_daily"
+  }'
+```
+
+提交小范围分钟线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1m"],
+    "start_date": "2026-05-20",
+    "end_date": "2026-05-20",
+    "symbols": ["600519.SH", "000001.SZ"],
+    "batch_size": 50,
+    "workers": 1,
+    "download_policy": "missing",
+    "reason": "manual_small_minute_patch"
+  }'
+```
+
+提交全 A 单月分钟线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1m"],
+    "start_date": "2026-05-01",
+    "end_date": "2026-05-31",
+    "batch_size": 50,
+    "workers": 2,
+    "download_policy": "missing",
+    "reason": "manual_full_a_month_1m"
+  }'
+```
+
+查询任务状态:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/jobs/FKL_xxx"
+```
+
+查询最近任务:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/jobs?limit=20"
+```
+
+查询覆盖统计:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/coverage?period=1m&start_date=2026-05-01&end_date=2026-05-31&limit=200"
+```
+
+触发本地 `1m -> 60m` 派生:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/derive_60m" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "start_date": "2026-05-20",
+    "end_date": "2026-05-20",
+    "symbols": ["600519.SH"],
+    "batch_size": 50,
+    "reason": "manual_derive_60m"
+  }'
+```
+
+## 4. `POST /jobs` 参数说明
+
+| 参数 | 类型 | 说明 | 推荐值 |
+| --- | --- | --- | --- |
+| `mode` | string | 任务模式 | `history_backfill`、`incremental`、`validate` |
+| `periods` | array | 粒度 | `["1d"]`、`["1m"]`、`["60m"]` |
+| `start_date` | string | 开始日期 | `YYYY-MM-DD` |
+| `end_date` | string | 结束日期 | `YYYY-MM-DD` |
+| `symbols` | array/string | 股票列表;不传表示全 A | 小任务建议显式传 |
+| `batch_size` | int | 每批股票数 | `1m` 建议 `50` |
+| `workers` | int | 批次并发数 | `1` 或 `2` |
+| `download_policy` | string | 下载策略 | 建议 `missing` |
+| `reason` | string | 任务说明 | 建议填写 |
+
+`download_policy` 说明:
+
+| 值 | 含义 |
+| --- | --- |
+| `none` | 不主动下载,直接读取迅投本地已有数据 |
+| `force` | 强制调用迅投下载 |
+| `missing` | 先查本地 MySQL,已有足够数据就跳过,不足再下载 |
+
+## 5. 任务状态说明
+
+| 字段 | 说明 |
+| --- | --- |
+| `job_id` | 任务 ID,后续查询状态使用 |
+| `status` | `pending`、`running`、`success`、`partial_success`、`failed`、`cancelled` |
+| `total_batches` | 总批次数 |
+| `completed_batches` | 已完成批次数 |
+| `failed_batches` | 失败批次数 |
+| `rows_written` | 写入影响行数 |
+| `last_error` | 最近错误 |
+| `batch_errors` | 批次错误列表 |
+
+## 6. MySQL 主表说明
+
+### 6.1 日线表
+
+表名:`cn_stock_kline_1d_front`
+
+用途:前复权日 K 线。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `symbol` | 股票代码,例如 `600519.SH` |
+| `trade_date` | 交易日 |
+| `open` | 前复权开盘价 |
+| `high` | 前复权最高价 |
+| `low` | 前复权最低价 |
+| `close` | 前复权收盘价 |
+| `pre_close` | 前复权昨收 |
+| `volume` | 成交量 |
+| `amount` | 成交额 |
+| `turnover` | 迅投返回的换手/成交相关字段 |
+| `source_batch_id` | 写入批次 |
+
+### 6.2 分钟线表
+
+表名:`cn_stock_kline_1m_front`
+
+用途:前复权 1 分钟 K 线。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `symbol` | 股票代码 |
+| `bar_time` | 分钟时间 |
+| `open` | 前复权开盘价 |
+| `high` | 前复权最高价 |
+| `low` | 前复权最低价 |
+| `close` | 前复权收盘价 |
+| `volume` | 成交量 |
+| `amount` | 成交额 |
+| `turnover` | 迅投返回字段 |
+| `source_batch_id` | 写入批次 |
+
+### 6.3 60 分钟线表
+
+表名:`cn_stock_kline_60m_front`
+
+用途:前复权 60 分钟 K 线;可以由迅投直接提供,也可以从本地 `1m` 派生。
+
+字段与 `cn_stock_kline_1m_front` 基本一致,时间字段同为 `bar_time`。
+
+### 6.4 覆盖统计表
+
+表名:`cn_stock_kline_front_coverage_daily`
+
+用途:按交易日和粒度统计覆盖情况,判断是否补齐。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `period` | `1d`、`1m`、`60m` |
+| `trade_date` | 交易日 |
+| `table_name` | 对应物理表 |
+| `row_count` | 当天该粒度总行数 |
+| `symbol_count` | 当天覆盖股票数 |
+| `expected_symbol_count` | 期望股票数 |
+| `coverage_ratio` | 覆盖率 |
+| `min_bar_time` | 分钟/60 分钟最早时间 |
+| `max_bar_time` | 分钟/60 分钟最晚时间 |
+
+## 7. 常用 SQL
+
+查单股日线:
+
+```sql
+SELECT symbol, trade_date, open, high, low, close, pre_close, volume, amount
+FROM cn_stock_kline_1d_front
+WHERE symbol = '600519.SH'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-26'
+ORDER BY trade_date;
+```
+
+查单股分钟线:
+
+```sql
+SELECT symbol, bar_time, open, high, low, close, volume, amount
+FROM cn_stock_kline_1m_front
+WHERE symbol = '600519.SH'
+  AND bar_time >= '2026-05-20 00:00:00'
+  AND bar_time <  '2026-05-21 00:00:00'
+ORDER BY bar_time;
+```
+
+查单股 60 分钟线:
+
+```sql
+SELECT symbol, bar_time, open, high, low, close, volume, amount, source
+FROM cn_stock_kline_60m_front
+WHERE symbol = '600519.SH'
+  AND bar_time >= '2026-05-20 00:00:00'
+  AND bar_time <  '2026-05-21 00:00:00'
+ORDER BY bar_time;
+```
+
+查某日全 A 日线覆盖:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1d'
+  AND trade_date = '2026-05-20';
+```
+
+查某月分钟线覆盖:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio, min_bar_time, max_bar_time
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1m'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-31'
+ORDER BY trade_date;
+```
+
+查覆盖不足日期:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1m'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-31'
+  AND (expected_symbol_count IS NULL OR symbol_count < expected_symbol_count)
+ORDER BY trade_date;
+```
+
+## 8. 推荐工作流
+
+1. 先用 HTTP `/health` 确认服务在线。
+2. 用 MySQL 覆盖统计表确认目标日期和粒度是否已有数据。
+3. 如果缺数据,再通过 HTTP `/jobs` 或 `/repair` 触发补数。
+4. 用 `GET /jobs/{job_id}` 每 `10-30` 秒查看任务状态。
+5. 任务完成后,再从 MySQL 主表读取数据。
+6. 如果任务产生 `still_missing_after_download`,由服务负责人根据 `gap_records.csv` 统一补缺。
+
+## 9. 注意事项
+
+1. `symbols` 不传表示全 A,会产生大任务。
+2. `1m` 数据量最大,建议按单日、单月或明确股票集合补。
+3. `workers` 建议不超过 `2`。
+4. `1m` 的 `batch_size` 建议使用 `50`。
+5. 服务依赖本机迅投客户端、MySQL 和补数服务进程同时在线。
+6. MySQL 查询大表时必须带 `symbol` 和日期范围,避免全表扫描。
+7. 外部同事读取数据时优先读 `*_front` 表,不要混用旧的不复权表。
diff --git "a/data/K\347\272\277\345\220\214\346\255\245\345\210\260\346\234\254\345\234\260\346\225\260\346\215\256\345\272\223\345\205\250\346\231\257\350\257\264\346\230\216.md" "b/data/K\347\272\277\345\220\214\346\255\245\345\210\260\346\234\254\345\234\260\346\225\260\346\215\256\345\272\223\345\205\250\346\231\257\350\257\264\346\230\216.md"
new file mode 100644
index 0000000..cd20f5a
--- /dev/null
+++ "b/data/K\347\272\277\345\220\214\346\255\245\345\210\260\346\234\254\345\234\260\346\225\260\346\215\256\345\272\223\345\205\250\346\231\257\350\257\264\346\230\216.md"
@@ -0,0 +1,659 @@
+# K线同步到本地数据库全景说明
+
+更新时间:2026-06-11
+
+## 1. 背景
+
+`data` 目录下已有两份前复权 K 线补数服务文档:
+
+- `HTTP与MySQL使用说明.md`
+- `HTTP与MySQL使用说明.local.md`
+
+这两份文档说明了外部前复权 K 线服务如何通过 HTTP 查询任务/覆盖率,以及如何通过外部 MySQL 读取前复权日线、分钟线和 60 分钟线。
+
+当前目标不是改造对方服务,也不是直接覆盖本地现有行情主表,而是在本地建立一个可定期运行的增量同步入口,把外部前复权 K 线读到本地 MySQL 的独立镜像表里。
+
+## 2. 用户硬要求
+
+1. 不要改对方数据库的任何东西。
+2. 咱们这边增量存储,不覆盖、不替换、不改旧行;允许新增表,也允许对本地现有主表做 append-only 追加。
+3. 本文档要描述整个事件全景,并把两份原始连接/使用文档内容复制进来。
+
+## 3. 当前实现
+
+同步脚本:
+
+```text
+scripts/kline_front_incremental_sync.py
+```
+
+脚本边界:
+
+- 只读外部 MySQL。
+- 不调用外部 HTTP `/jobs`、`/repair`、`/derive_60m`,因此不会触发对方库写入。
+- 默认先写本地镜像表;如用户确认,可把镜像表中主表不存在的新日期/新主键 append 到本地现有主行情表。
+- 只写本地新增镜像表和同步日志表。
+
+本地新增表:
+
+```text
+a_share_daily_price_front_sync
+a_share_minute_price_front_sync
+a_share_kline_front_sync_run
+```
+
+外部读取表:
+
+```text
+cn_stock_kline_1d_front
+cn_stock_kline_1m_front
+```
+
+当前暂不自动同步 `cn_stock_kline_60m_front`。如果后续需要 60 分钟线,应按同样模式新增 `a_share_60m_price_front_sync`,仍然保持只读外部库、本地增量镜像的边界。
+
+## 4. 同步策略
+
+### 4.1 源端策略
+
+源端只读:
+
+- 使用外部 MySQL 查询 `cn_stock_kline_1d_front` 和 `cn_stock_kline_1m_front`。
+- 不执行 `INSERT / UPDATE / DELETE / CREATE / DROP`。
+- 不通过 HTTP 提交补数任务。
+
+如果外部源缺数据,脚本只报告源端无数据或覆盖不足,不负责补外部库。
+
+### 4.2 本地策略
+
+本地增量镜像:
+
+- 日线写入 `a_share_daily_price_front_sync`。
+- 分钟线写入 `a_share_minute_price_front_sync`。
+- 同步过程写入 `a_share_kline_front_sync_run`。
+
+主表保护:
+
+- 不覆盖、不替换、不更新 `a_share_daily_price` 旧行。
+- 不覆盖、不替换、不更新 `a_share_minute_price` 旧行。
+- 允许把镜像表中主表不存在的新主键 append 到主表。
+- 不写 `a_share_trading_calendar`。
+- 不替换现有交易系统、局系统、实验系统正在读取的表。
+
+以后如果要切换主流程读取新数据,必须另起需求和迁移步骤,不应由本同步脚本直接完成。
+
+### 4.3 增量起点
+
+如果没有显式传入 `--start-date`:
+
+- 日线优先从 `a_share_daily_price_front_sync` 的最大 `trade_date + 1` 开始。
+- 如果镜像表为空,则从当前主表 `a_share_daily_price` 的最大 `trade_date + 1` 开始。
+- 分钟线同理,优先看 `a_share_minute_price_front_sync`,再看 `a_share_minute_price`。
+
+如果显式传入 `--start-date / --end-date`,以 CLI 参数为准。
+
+## 5. 运行方式
+
+### 5.1 环境变量
+
+建议使用环境变量传密码,不在命令行里明文写密码。
+
+```powershell
+$env:KLINE_SOURCE_MYSQL_PASSWORD='外部源密码'
+$env:TIANXIA_MYSQL_PASSWORD='本地MySQL密码'
+```
+
+可选连接变量:
+
+```powershell
+$env:KLINE_SOURCE_MYSQL_HOST='317w7246e5.vicp.fun'
+$env:KLINE_SOURCE_MYSQL_PORT='50176'
+$env:KLINE_SOURCE_MYSQL_USER='root'
+$env:KLINE_SOURCE_MYSQL_DB='trading_xuntou'
+
+$env:TIANXIA_MYSQL_HOST='127.0.0.1'
+$env:TIANXIA_MYSQL_PORT='3306'
+$env:TIANXIA_MYSQL_USER='root'
+$env:TIANXIA_MYSQL_DB='tianxia'
+```
+
+### 5.2 初始化表结构
+
+```powershell
+python scripts/kline_front_incremental_sync.py `
+  --run-id kline_front_sync_init_schema `
+  --init-schema-only
+```
+
+### 5.3 干跑检查
+
+```powershell
+python scripts/kline_front_incremental_sync.py `
+  --run-id kline_front_sync_dryrun `
+  --periods 1d 1m `
+  --dry-run
+```
+
+### 5.4 同步日线和分钟线
+
+```powershell
+python scripts/kline_front_incremental_sync.py `
+  --run-id kline_front_sync_20260529 `
+  --periods 1d 1m `
+  --append-to-main
+```
+
+`--append-to-main` 的含义:
+
+- 先写镜像表。
+- 每个成功批次完成后,把主表不存在的新主键追加进主表。
+- 不更新、不覆盖、不删除主表旧行。
+- 默认只允许全 A 同步追加主表;如果传了 `--symbols`,脚本会拒绝主表追加,除非显式加 `--allow-partial-main-append`。
+
+### 5.5 指定日期范围
+
+```powershell
+python scripts/kline_front_incremental_sync.py `
+  --run-id kline_front_sync_20260509_20260529 `
+  --periods 1d 1m `
+  --start-date 2026-05-09 `
+  --end-date 2026-05-29 `
+  --append-to-main
+```
+
+### 5.6 小范围验证
+
+```powershell
+python scripts/kline_front_incremental_sync.py `
+  --run-id kline_front_sync_one_symbol_check `
+  --periods 1d 1m `
+  --start-date 2026-05-08 `
+  --end-date 2026-05-08 `
+  --symbols 600519.SH
+```
+
+单股/局部检查默认不追加主表,避免把主表变成某天只有少数股票的稀疏数据。只有明确做局部修复时,才允许加:
+
+```powershell
+--append-to-main --allow-partial-main-append
+```
+
+## 6. 周期调度建议
+
+Windows Task Scheduler 可以每天盘后运行一次。
+
+建议节奏:
+
+- 日线:每天盘后同步一次。
+- 分钟线:每天盘后同步当日,或按周补齐。
+- 大范围分钟线:不要一次多年全 A,同步脚本虽然支持日期范围,但建议按小日期窗口跑。
+
+推荐命令形态:
+
+```powershell
+powershell.exe -ExecutionPolicy Bypass -Command "$env:KLINE_SOURCE_MYSQL_PASSWORD='***'; $env:TIANXIA_MYSQL_PASSWORD='***'; python scripts/kline_front_incremental_sync.py --periods 1d 1m"
+```
+
+## 7. 已做自检
+
+2026-05-29 已完成脚本级自检:
+
+- `python -m py_compile scripts/kline_front_incremental_sync.py` 通过。
+- 单股日线实际同步:`600519.SH`,`2026-05-08`,源端 1 行,本地镜像写入 1 行。
+- 单股分钟线实际同步:`600519.SH`,`2026-05-08`,源端 241 行,本地镜像写入 241 行。
+
+自检只证明脚本链路可用,不代表已完成全 A 全量同步。
+
+## 7.1 当前覆盖差距快照
+
+2026-05-29 查询到的日期范围:
+
+| 数据面 | 源端范围 | 本地主表范围 | 读法 |
+| --- | --- | --- | --- |
+| 日线 `1d` | `2023-01-03` 到 `2026-05-26` | `2023-01-03` 到 `2026-05-08` | 本地日线可从 `2026-05-09` 起增量补到源端最新。 |
+| 分钟线 `1m` | `2026-01-05` 到 `2026-05-26` | `2023-03-24` 到 `2026-04-02` | 本地分钟线可从 `2026-04-03` 起增量补到源端最新;源端当前不是完整 2023-2025 分钟历史源。 |
+
+该快照只记录日期范围,不代表覆盖率全量通过。正式切换前仍需按覆盖率和数据质量单独校验。
+
+## 7.2 2026-05-29 实际同步结果
+
+已完成:
+
+- 日线 `1d`:已从源端同步 `2026-05-09` 到 `2026-05-26` 的全 A 数据到本地镜像表 `a_share_daily_price_front_sync`。
+- 同步行数:`65,953`。
+- 同步 run_id:`20260529_kline_front_sync_daily_20260509_20260526_codex`。
+- 按 append-only 口径,已将镜像表中主表不存在的 `65,953` 行追加到本地主表 `a_share_daily_price`。
+- 追加后 `a_share_daily_price` 范围:`2023-01-03` 到 `2026-05-26`。
+- 追加后待追加剩余数:`0`。
+- 追加范围重复主键数:`0`。
+
+未完成:
+
+- 分钟线 `1m`:源端 `2026-04-03` 到 `2026-05-26` 共有约 `44,967,408` 行需要补到本地。
+- 通过“远端 MySQL 明细查询 -> 本地 TSV -> LOAD DATA”的跨网方式,按全市场时间窗口直接扫描会很慢;已改为按真实 A 股交易时段 + 每批 50 只股票同步,避免全市场大窗口扫描。
+- 已中断早期慢查询尝试,并标记为 `INTERRUPTED_*`,避免半成品被误读。
+- `20260529_ds_minute_append_20260403_20260526_opt4_symbolbatch_sessions` 为当前正式后台同步 run:按 `09:30-11:31 / 13:00-15:01` 交易时段、30 分钟窗口、50 只股票一批拉取;每个成功批次写入镜像表后 append-only 写入 `a_share_minute_price`。
+- 本地当前主表 `a_share_minute_price` 已开始由该 run 推进;截至启动后首轮检查,最大日期已从 `2026-04-02` 推进到 `2026-04-03`,后续仍在后台继续补齐到源端 `2026-05-26`。
+
+当前判断:
+
+- 日线可以按该脚本定期增量同步,并在确认后 append 到本地主表。
+- 分钟线全 A 大范围同步不适合用当前前台直拉方式硬跑;需要换成压缩文件交付、服务端导出、内网直连、后台长任务,或按策略实际需要的股票/日期切片同步。
+
+## 7.2.1 2026-06-11 源端补数与本地日线同步结果
+
+已完成:
+
+- 先按源端覆盖表确认 `2026-05-27` 到 `2026-06-10` 日线缺口:补数前源端覆盖表仅有 `2026-06-03` 的 `1` 行异常稀疏记录。
+- 已通过 HTTP `POST /jobs` 提交全 A 日线补数任务,参数口径为 `periods=["1d"]`、`download_policy="missing"`、`batch_size=500`、`workers=1`。
+- 源端补数 job_id:`FKL_20260611_022420_b86d6421`。
+- 源端任务状态:`success`;`total_batches=24`,`completed_batches=24`,`failed_batches=0`,任务返回 `rows_written=60604`。
+- 补数后源端主表 `cn_stock_kline_1d_front` 在 `2026-05-27` 到 `2026-06-10` 范围内共有 `60,603` 行,最大日期为 `2026-06-10`。
+- 已执行本地日线同步 run_id:`ds_daily_after_upstream_backfill_20260611_023303`。
+- 本地镜像表 `a_share_daily_price_front_sync` 写入 `60,603` 行。
+- 按 append-only 口径,已将主表不存在的 `60,603` 行追加到本地主表 `a_share_daily_price`。
+- 追加后 `a_share_daily_price` 范围:`2023-01-03` 到 `2026-06-10`;总行数 `4,394,085`。
+
+核对结果:
+
+- 本地主表 `2026-05-27` 到 `2026-06-10` 范围内共有 `60,603` 行。
+- 分日行数:`2026-05-27=5506`、`2026-05-28=5505`、`2026-05-29=5505`、`2026-06-01=5507`、`2026-06-02=5506`、`2026-06-03=5510`、`2026-06-04=5510`、`2026-06-05=5513`、`2026-06-08=5514`、`2026-06-09=5515`、`2026-06-10=5512`。
+- 源端覆盖统计表在本次检查时已登记到 `2026-06-09`,源端主表已包含 `2026-06-10`;该差异按覆盖统计表滞后处理,后续可再次复核。
+- 未触发分钟线补数;未覆盖、更新或删除本地主表旧行。
+
+## 7.3 2026-05-29 脚本口径更新
+
+同步脚本已新增:
+
+```text
+--append-to-main
+--allow-partial-main-append
+```
+
+后续 `ds` 默认口径:
+
+- 日线:同步后自动 append 到 `a_share_daily_price`。
+- 分钟线:按真实交易时段、30 分钟窗口、每批 50 只股票同步;每个成功批次同步后自动 append 到 `a_share_minute_price`。
+- 不覆盖任何旧行。
+- 不允许默认把单股/局部数据 append 到主表,除非显式声明是局部修复。
+
+已回归:
+
+- 使用 `2026-05-26` 日线全 A 重跑 `--append-to-main`。
+- 镜像表同步 `5,501` 行。
+- 主表已存在对应主键,因此主表追加 `0` 行。
+- 证明 append-only 逻辑不会覆盖旧数据。
+
+## 8. 不做的事
+
+本同步脚本不做这些事:
+
+- 不修改外部 `trading_xuntou` 数据库。
+- 不提交外部 HTTP 补数任务。
+- 不覆盖本地 `a_share_daily_price`。
+- 不覆盖本地 `a_share_minute_price`。
+- 不自动切换交易系统或局系统的数据源。
+- 不证明外部数据质量完全可靠。
+
+## 9. 后续如果要正式切换主数据源
+
+必须另起一轮明确步骤:
+
+1. 对 `a_share_daily_price_front_sync / a_share_minute_price_front_sync` 做覆盖率、重复主键、OHLC 空值、交易日历、分钟完整性校验。
+2. 抽样比对旧主表和新镜像表。
+3. 生成数据源切换说明。
+4. 再决定是否把实验或交易系统读取源切到镜像表。
+
+当前脚本只负责“安全增量镜像”,不负责“正式切换主源”。
+
+---
+
+# 附录 A:`HTTP与MySQL使用说明.md` 原文复制
+
+# 前复权 K 线补数服务 HTTP 与 MySQL 使用说明
+
+- 适用对象:内部同事通过 HTTP 触发补数任务,并通过 MySQL 读取前复权 K 线数据。
+- HTTP 外部地址:`http://317w7246e5.vicp.fun:12181`
+- MySQL 外部地址:`317w7246e5.vicp.fun:50176`
+- MySQL 数据库:`trading_xuntou`
+- MySQL 用户:`root`
+- MySQL 密码:请向服务负责人获取,正式文档不写明文密码。
+
+## 1. 使用原则
+
+1. HTTP 接口只用于提交任务、查询任务状态、查询覆盖率、触发修复和触发 60 分钟线派生。
+2. 行情数据读取以 MySQL 为主,不建议通过 HTTP 查询大批量行情。
+3. 当前 K 线主表均为前复权口径,优先使用 `*_front` 表。
+4. 分钟线任务数据量很大,禁止随意提交全 A 多年分钟线任务。
+5. 大范围分钟线补数建议按月份执行,参数使用 `download_policy=missing`。
+6. 轮询任务状态建议间隔 `10-30` 秒,不要高频请求。
+
+## 2. HTTP 接口清单
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| `GET` | `/health` | 检查服务是否在线 |
+| `POST` | `/jobs` | 提交补数或校验任务 |
+| `GET` | `/jobs` | 查询最近任务列表 |
+| `GET` | `/jobs/{job_id}` | 查询单个任务状态 |
+| `GET` | `/coverage` | 查询覆盖统计 |
+| `POST` | `/repair` | 提交缺口修复任务 |
+| `POST` | `/derive_60m` | 从本地 `1m` 派生 `60m` |
+
+## 3. 常用 HTTP 示例
+
+检查服务:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/health"
+```
+
+提交单股日线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1d"],
+    "start_date": "2026-05-01",
+    "end_date": "2026-05-26",
+    "symbols": ["600519.SH"],
+    "batch_size": 1,
+    "download_policy": "missing",
+    "reason": "manual_single_symbol_daily"
+  }'
+```
+
+提交小范围分钟线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1m"],
+    "start_date": "2026-05-20",
+    "end_date": "2026-05-20",
+    "symbols": ["600519.SH", "000001.SZ"],
+    "batch_size": 50,
+    "workers": 1,
+    "download_policy": "missing",
+    "reason": "manual_small_minute_patch"
+  }'
+```
+
+提交全 A 单月分钟线补数:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/jobs" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "history_backfill",
+    "periods": ["1m"],
+    "start_date": "2026-05-01",
+    "end_date": "2026-05-31",
+    "batch_size": 50,
+    "workers": 2,
+    "download_policy": "missing",
+    "reason": "manual_full_a_month_1m"
+  }'
+```
+
+查询任务状态:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/jobs/FKL_xxx"
+```
+
+查询最近任务:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/jobs?limit=20"
+```
+
+查询覆盖统计:
+
+```bash
+curl "http://317w7246e5.vicp.fun:12181/coverage?period=1m&start_date=2026-05-01&end_date=2026-05-31&limit=200"
+```
+
+触发本地 `1m -> 60m` 派生:
+
+```bash
+curl -X POST "http://317w7246e5.vicp.fun:12181/derive_60m" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "start_date": "2026-05-20",
+    "end_date": "2026-05-20",
+    "symbols": ["600519.SH"],
+    "batch_size": 50,
+    "reason": "manual_derive_60m"
+  }'
+```
+
+## 4. `POST /jobs` 参数说明
+
+| 参数 | 类型 | 说明 | 推荐值 |
+| --- | --- | --- | --- |
+| `mode` | string | 任务模式 | `history_backfill`、`incremental`、`validate` |
+| `periods` | array | 粒度 | `["1d"]`、`["1m"]`、`["60m"]` |
+| `start_date` | string | 开始日期 | `YYYY-MM-DD` |
+| `end_date` | string | 结束日期 | `YYYY-MM-DD` |
+| `symbols` | array/string | 股票列表;不传表示全 A | 小任务建议显式传 |
+| `batch_size` | int | 每批股票数 | `1m` 建议 `50` |
+| `workers` | int | 批次并发数 | `1` 或 `2` |
+| `download_policy` | string | 下载策略 | 建议 `missing` |
+| `reason` | string | 任务说明 | 建议填写 |
+
+`download_policy` 说明:
+
+| 值 | 含义 |
+| --- | --- |
+| `none` | 不主动下载,直接读取迅投本地已有数据 |
+| `force` | 强制调用迅投下载 |
+| `missing` | 先查本地 MySQL,已有足够数据就跳过,不足再下载 |
+
+## 5. 任务状态说明
+
+| 字段 | 说明 |
+| --- | --- |
+| `job_id` | 任务 ID,后续查询状态使用 |
+| `status` | `pending`、`running`、`success`、`partial_success`、`failed`、`cancelled` |
+| `total_batches` | 总批次数 |
+| `completed_batches` | 已完成批次数 |
+| `failed_batches` | 失败批次数 |
+| `rows_written` | 写入影响行数 |
+| `last_error` | 最近错误 |
+| `batch_errors` | 批次错误列表 |
+
+## 6. MySQL 主表说明
+
+### 6.1 日线表
+
+表名:`cn_stock_kline_1d_front`
+
+用途:前复权日 K 线。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `symbol` | 股票代码,例如 `600519.SH` |
+| `trade_date` | 交易日 |
+| `open` | 前复权开盘价 |
+| `high` | 前复权最高价 |
+| `low` | 前复权最低价 |
+| `close` | 前复权收盘价 |
+| `pre_close` | 前复权昨收 |
+| `volume` | 成交量 |
+| `amount` | 成交额 |
+| `turnover` | 迅投返回的换手/成交相关字段 |
+| `source_batch_id` | 写入批次 |
+
+### 6.2 分钟线表
+
+表名:`cn_stock_kline_1m_front`
+
+用途:前复权 1 分钟 K 线。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `symbol` | 股票代码 |
+| `bar_time` | 分钟时间 |
+| `open` | 前复权开盘价 |
+| `high` | 前复权最高价 |
+| `low` | 前复权最低价 |
+| `close` | 前复权收盘价 |
+| `volume` | 成交量 |
+| `amount` | 成交额 |
+| `turnover` | 迅投返回字段 |
+| `source_batch_id` | 写入批次 |
+
+### 6.3 60 分钟线表
+
+表名:`cn_stock_kline_60m_front`
+
+用途:前复权 60 分钟 K 线;可以由迅投直接提供,也可以从本地 `1m` 派生。
+
+字段与 `cn_stock_kline_1m_front` 基本一致,时间字段同为 `bar_time`。
+
+### 6.4 覆盖统计表
+
+表名:`cn_stock_kline_front_coverage_daily`
+
+用途:按交易日和粒度统计覆盖情况,判断是否补齐。
+
+核心字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `period` | `1d`、`1m`、`60m` |
+| `trade_date` | 交易日 |
+| `table_name` | 对应物理表 |
+| `row_count` | 当天该粒度总行数 |
+| `symbol_count` | 当天覆盖股票数 |
+| `expected_symbol_count` | 期望股票数 |
+| `coverage_ratio` | 覆盖率 |
+| `min_bar_time` | 分钟/60 分钟最早时间 |
+| `max_bar_time` | 分钟/60 分钟最晚时间 |
+
+## 7. 常用 SQL
+
+查单股日线:
+
+```sql
+SELECT symbol, trade_date, open, high, low, close, pre_close, volume, amount
+FROM cn_stock_kline_1d_front
+WHERE symbol = '600519.SH'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-26'
+ORDER BY trade_date;
+```
+
+查单股分钟线:
+
+```sql
+SELECT symbol, bar_time, open, high, low, close, volume, amount
+FROM cn_stock_kline_1m_front
+WHERE symbol = '600519.SH'
+  AND bar_time >= '2026-05-20 00:00:00'
+  AND bar_time <  '2026-05-21 00:00:00'
+ORDER BY bar_time;
+```
+
+查单股 60 分钟线:
+
+```sql
+SELECT symbol, bar_time, open, high, low, close, volume, amount, source
+FROM cn_stock_kline_60m_front
+WHERE symbol = '600519.SH'
+  AND bar_time >= '2026-05-20 00:00:00'
+  AND bar_time <  '2026-05-21 00:00:00'
+ORDER BY bar_time;
+```
+
+查某日全 A 日线覆盖:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1d'
+  AND trade_date = '2026-05-20';
+```
+
+查某月分钟线覆盖:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio, min_bar_time, max_bar_time
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1m'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-31'
+ORDER BY trade_date;
+```
+
+查覆盖不足日期:
+
+```sql
+SELECT period, trade_date, row_count, symbol_count, expected_symbol_count, coverage_ratio
+FROM cn_stock_kline_front_coverage_daily
+WHERE period = '1m'
+  AND trade_date BETWEEN '2026-05-01' AND '2026-05-31'
+  AND (expected_symbol_count IS NULL OR symbol_count < expected_symbol_count)
+ORDER BY trade_date;
+```
+
+## 8. 推荐工作流
+
+1. 先用 HTTP `/health` 确认服务在线。
+2. 用 MySQL 覆盖统计表确认目标日期和粒度是否已有数据。
+3. 如果缺数据,再通过 HTTP `/jobs` 或 `/repair` 触发补数。
+4. 用 `GET /jobs/{job_id}` 每 `10-30` 秒查看任务状态。
+5. 任务完成后,再从 MySQL 主表读取数据。
+6. 如果任务产生 `still_missing_after_download`,由服务负责人根据 `gap_records.csv` 统一补缺。
+
+## 9. 注意事项
+
+1. `symbols` 不传表示全 A,会产生大任务。
+2. `1m` 数据量最大,建议按单日、单月或明确股票集合补。
+3. `workers` 建议不超过 `2`。
+4. `1m` 的 `batch_size` 建议使用 `50`。
+5. 服务依赖本机迅投客户端、MySQL 和补数服务进程同时在线。
+6. MySQL 查询大表时必须带 `symbol` 和日期范围,避免全表扫描。
+7. 外部同事读取数据时优先读 `*_front` 表,不要混用旧的不复权表。
+
+---
+
+# 附录 B:`HTTP与MySQL使用说明.local.md` 原文复制
+
+# 前复权 K 线补数服务外部映射连接信息(本地私有)
+
+> 本文件包含明文连接信息,已通过 `.gitignore` 排除,不应提交到 Git。
+
+## HTTP
+
+- Base URL:`http://317w7246e5.vicp.fun:12181`
+
+## MySQL
+
+- Host:`317w7246e5.vicp.fun`
+- Port:`50176`
+- User:`root`
+- Password:`***`
+- Database:`trading_xuntou`
+
+## 建议发给同事的最小连接信息
+
+```text
+HTTP: http://317w7246e5.vicp.fun:12181
+MySQL: 317w7246e5.vicp.fun:50176
+Database: trading_xuntou
+User: root
+Password: 123456
+```
+
+正式使用说明见:
+
+```text
+data/HTTP与MySQL使用说明.md
+```
diff --git a/scripts/kline_front_incremental_sync.py b/scripts/kline_front_incremental_sync.py
new file mode 100644
index 0000000..03936ed
--- /dev/null
+++ b/scripts/kline_front_incremental_sync.py
@@ -0,0 +1,1010 @@
+"""Incrementally mirror front-adjusted K-line data from the shared source DB.
+
+This script is intentionally conservative:
+
+- The source MySQL is read-only from this script. It never writes to the
+  colleague-owned database and never calls the HTTP backfill endpoints.
+- Data is written first to local mirror tables suffixed with ``_front_sync``
+  plus a sync run log table. When ``--append-to-main`` is passed, only missing
+  primary keys are appended into local main tables; existing local rows are
+  never updated, replaced, or deleted.
+- The sync is idempotent by primary key and can be scheduled repeatedly.
+
+Required passwords are read from environment variables or CLI arguments. The
+script does not hard-code credentials.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import tempfile
+from dataclasses import dataclass
+from datetime import date, datetime, time, timedelta
+from pathlib import Path
+from typing import Iterable
+
+
+SCRIPT_PATH = Path(__file__).absolute()
+ROOT = SCRIPT_PATH.parents[2]
+RESULT_ROOT = ROOT / "data" / "result" / "stat"
+
+DEFAULT_MYSQL_EXE = Path(os.environ.get("TIANXIA_MYSQL_EXE", r"M:\mysql\server\bin\mysql.exe"))
+
+LOCAL_DAILY_TABLE = "a_share_daily_price_front_sync"
+LOCAL_MINUTE_TABLE = "a_share_minute_price_front_sync"
+LOCAL_RUN_TABLE = "a_share_kline_front_sync_run"
+MAIN_DAILY_TABLE = "a_share_daily_price"
+MAIN_MINUTE_TABLE = "a_share_minute_price"
+
+SOURCE_DAILY_TABLE = "cn_stock_kline_1d_front"
+SOURCE_MINUTE_TABLE = "cn_stock_kline_1m_front"
+SOURCE_COVERAGE_TABLE = "cn_stock_kline_front_coverage_daily"
+
+
+@dataclass(frozen=True)
+class MysqlConn:
+    mysql_exe: Path
+    host: str
+    port: str
+    user: str
+    password: str | None
+    database: str
+
+
+def sql_literal(value: object) -> str:
+    if value is None:
+        return "NULL"
+    text = str(value)
+    return "'" + text.replace("\\", "\\\\").replace("'", "''") + "'"
+
+
+def parse_date(value: str) -> date:
+    return datetime.strptime(value, "%Y-%m-%d").date()
+
+
+def date_to_str(value: date) -> str:
+    return value.strftime("%Y-%m-%d")
+
+
+def mysql_cmd(conn: MysqlConn, extra: Iterable[str] | None = None) -> list[str]:
+    cmd = [
+        str(conn.mysql_exe),
+        "--protocol=TCP",
+        "--local-infile=1",
+        "--compress",
+        f"--host={conn.host}",
+        f"--port={conn.port}",
+        f"--user={conn.user}",
+        "--default-character-set=utf8mb4",
+    ]
+    if extra:
+        cmd.extend(extra)
+    cmd.append(conn.database)
+    return cmd
+
+
+def mysql_env(conn: MysqlConn) -> dict[str, str]:
+    env = os.environ.copy()
+    if conn.password:
+        env["MYSQL_PWD"] = conn.password
+    return env
+
+
+def run_mysql(conn: MysqlConn, sql: str, *, batch: bool = False, skip_column_names: bool = False) -> str:
+    extra = []
+    if batch:
+        extra.extend(["--batch", "--raw"])
+    if skip_column_names:
+        extra.append("--skip-column-names")
+    cmd = mysql_cmd(conn, extra + ["-e", sql])
+    proc = subprocess.run(
+        cmd,
+        text=True,
+        capture_output=True,
+        env=mysql_env(conn),
+        check=False,
+    )
+    if proc.returncode != 0:
+        raise RuntimeError(proc.stderr.strip() or proc.stdout.strip())
+    return proc.stdout
+
+
+def query_rows(conn: MysqlConn, sql: str) -> list[dict[str, str]]:
+    out = run_mysql(conn, sql, batch=True)
+    lines = [line for line in out.splitlines() if line.strip()]
+    if not lines:
+        return []
+    header = lines[0].split("\t")
+    rows: list[dict[str, str]] = []
+    for line in lines[1:]:
+        values = line.split("\t")
+        rows.append({header[i]: values[i] if i < len(values) else "" for i in range(len(header))})
+    return rows
+
+
+def scalar(conn: MysqlConn, sql: str, field: str = "v") -> str:
+    rows = query_rows(conn, sql)
+    return rows[0].get(field, "") if rows else ""
+
+
+def export_tsv(conn: MysqlConn, sql: str, out_path: Path) -> None:
+    cmd = mysql_cmd(conn, ["--batch", "--raw", "--quick", "--skip-column-names", "-e", sql])
+    with out_path.open("wb") as fh:
+        proc = subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE, env=mysql_env(conn), check=False)
+    if proc.returncode != 0:
+        stderr = proc.stderr.decode("utf-8", errors="replace")
+        raise RuntimeError(stderr.strip())
+
+
+def local_exec(conn: MysqlConn, sql: str) -> str:
+    return run_mysql(conn, sql)
+
+
+def create_local_schema(conn: MysqlConn) -> None:
+    local_exec(
+        conn,
+        f"""
+CREATE TABLE IF NOT EXISTS {LOCAL_DAILY_TABLE} (
+  trade_date DATE NOT NULL,
+  symbol CHAR(9) NOT NULL,
+  open_price DECIMAL(12,4) NULL,
+  high_price DECIMAL(12,4) NULL,
+  low_price DECIMAL(12,4) NULL,
+  close_price DECIMAL(12,4) NULL,
+  pre_close_price DECIMAL(12,4) NULL,
+  volume BIGINT UNSIGNED NULL,
+  amount DECIMAL(20,2) NULL,
+  turnover DOUBLE NULL,
+  source_batch_id VARCHAR(64) NULL,
+  source_table VARCHAR(64) NOT NULL DEFAULT '{SOURCE_DAILY_TABLE}',
+  source_updated_at DATETIME NULL,
+  sync_run_id VARCHAR(96) NOT NULL,
+  synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (trade_date, symbol),
+  KEY idx_symbol_date (symbol, trade_date),
+  KEY idx_sync_run (sync_run_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS {LOCAL_MINUTE_TABLE} (
+  trade_date DATE NOT NULL,
+  trade_time TIME NOT NULL,
+  bar_time DATETIME NOT NULL,
+  symbol CHAR(9) NOT NULL,
+  open_price DECIMAL(12,4) NULL,
+  high_price DECIMAL(12,4) NULL,
+  low_price DECIMAL(12,4) NULL,
+  close_price DECIMAL(12,4) NULL,
+  volume BIGINT UNSIGNED NULL,
+  amount DECIMAL(20,2) NULL,
+  turnover DOUBLE NULL,
+  source_batch_id VARCHAR(64) NULL,
+  source_table VARCHAR(64) NOT NULL DEFAULT '{SOURCE_MINUTE_TABLE}',
+  source_updated_at DATETIME NULL,
+  sync_run_id VARCHAR(96) NOT NULL,
+  synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (trade_date, trade_time, symbol),
+  KEY idx_symbol_bar_time (symbol, bar_time),
+  KEY idx_bar_time (bar_time),
+  KEY idx_sync_run (sync_run_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS {LOCAL_RUN_TABLE} (
+  run_id VARCHAR(96) NOT NULL,
+  started_at DATETIME NOT NULL,
+  finished_at DATETIME NULL,
+  status VARCHAR(64) NOT NULL,
+  periods VARCHAR(64) NOT NULL,
+  start_date DATE NULL,
+  end_date DATE NULL,
+  symbols_scope TEXT NULL,
+  source_host VARCHAR(255) NOT NULL,
+  source_database VARCHAR(128) NOT NULL,
+  local_database VARCHAR(128) NOT NULL,
+  daily_rows_source BIGINT NOT NULL DEFAULT 0,
+  daily_rows_loaded BIGINT NOT NULL DEFAULT 0,
+  minute_rows_source BIGINT NOT NULL DEFAULT 0,
+  minute_rows_loaded BIGINT NOT NULL DEFAULT 0,
+  summary_json JSON NULL,
+  error_message TEXT NULL,
+  PRIMARY KEY (run_id),
+  KEY idx_started_at (started_at),
+  KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+""",
+    )
+
+
+def table_exists(conn: MysqlConn, table_name: str) -> bool:
+    value = scalar(
+        conn,
+        "SELECT COUNT(*) AS v FROM information_schema.tables "
+        f"WHERE table_schema = DATABASE() AND table_name = {sql_literal(table_name)}",
+    )
+    return value not in ("", "0")
+
+
+def max_date(conn: MysqlConn, table_name: str, column_name: str) -> str:
+    if not table_exists(conn, table_name):
+        return ""
+    return scalar(conn, f"SELECT DATE(MAX({column_name})) AS v FROM {table_name}")
+
+
+def max_successful_all_a_sync_date(conn: MysqlConn, period: str) -> str:
+    if not table_exists(conn, LOCAL_RUN_TABLE):
+        return ""
+    if period == "1d" and table_exists(conn, LOCAL_DAILY_TABLE):
+        return scalar(
+            conn,
+            f"""
+SELECT DATE(MAX(d.trade_date)) AS v
+FROM {LOCAL_DAILY_TABLE} d
+JOIN {LOCAL_RUN_TABLE} r ON r.run_id = d.sync_run_id
+WHERE r.status = 'SUCCESS'
+  AND r.symbols_scope = 'ALL_A'
+  AND FIND_IN_SET('1d', r.periods) > 0
+""",
+        )
+    if period == "1m" and table_exists(conn, LOCAL_MINUTE_TABLE):
+        return scalar(
+            conn,
+            f"""
+SELECT DATE(MAX(m.trade_date)) AS v
+FROM {LOCAL_MINUTE_TABLE} m
+JOIN {LOCAL_RUN_TABLE} r ON r.run_id = m.sync_run_id
+WHERE r.status = 'SUCCESS'
+  AND r.symbols_scope = 'ALL_A'
+  AND FIND_IN_SET('1m', r.periods) > 0
+""",
+        )
+    return ""
+
+
+def source_max_date(conn: MysqlConn, period: str) -> str:
+    if period == "1d":
+        return scalar(conn, f"SELECT DATE(MAX(trade_date)) AS v FROM {SOURCE_DAILY_TABLE}")
+    if period == "1m":
+        return scalar(conn, f"SELECT DATE(MAX(bar_time)) AS v FROM {SOURCE_MINUTE_TABLE}")
+    raise ValueError(f"unsupported period: {period}")
+
+
+def resolve_start_date(local_conn: MysqlConn, period: str, explicit_start: str | None) -> date:
+    if explicit_start:
+        return parse_date(explicit_start)
+    if period == "1d":
+        local_latest = max_successful_all_a_sync_date(local_conn, "1d") or max_date(local_conn, "a_share_daily_price", "trade_date")
+    elif period == "1m":
+        local_latest = max_successful_all_a_sync_date(local_conn, "1m") or max_date(local_conn, "a_share_minute_price", "trade_date")
+    else:
+        raise ValueError(f"unsupported period: {period}")
+    if not local_latest:
+        raise RuntimeError(f"cannot infer start date for {period}; pass --start-date explicitly")
+    return parse_date(local_latest) + timedelta(days=1)
+
+
+def resolve_end_date(source_conn: MysqlConn, period: str, explicit_end: str | None) -> date:
+    if explicit_end:
+        return parse_date(explicit_end)
+    latest = source_max_date(source_conn, period)
+    if not latest:
+        raise RuntimeError(f"source has no max date for {period}")
+    return parse_date(latest)
+
+
+def date_batches(start: date, end: date, max_days: int) -> Iterable[tuple[date, date]]:
+    cur = start
+    while cur <= end:
+        batch_end = min(cur + timedelta(days=max_days - 1), end)
+        yield cur, batch_end
+        cur = batch_end + timedelta(days=1)
+
+
+def datetime_batches(start: datetime, end: datetime, max_minutes: int) -> Iterable[tuple[datetime, datetime]]:
+    cur = start
+    delta = timedelta(minutes=max_minutes)
+    while cur < end:
+        batch_end = min(cur + delta, end)
+        yield cur, batch_end
+        cur = batch_end
+
+
+def market_datetime_batches(day: date, max_minutes: int) -> Iterable[tuple[datetime, datetime]]:
+    """Yield only likely A-share minute windows, avoiding overnight empty scans."""
+    sessions = [
+        (time(9, 30), time(11, 31)),
+        (time(13, 0), time(15, 1)),
+    ]
+    for session_start, session_end in sessions:
+        yield from datetime_batches(datetime.combine(day, session_start), datetime.combine(day, session_end), max_minutes)
+
+
+def symbol_filter(symbols: list[str]) -> str:
+    if not symbols:
+        return ""
+    values = ",".join(sql_literal(symbol.strip()) for symbol in symbols if symbol.strip())
+    if not values:
+        return ""
+    return f" AND symbol IN ({values})"
+
+
+def count_source_rows(source_conn: MysqlConn, period: str, start: date, end: date, symbols: list[str]) -> int:
+    filt = symbol_filter(symbols)
+    if period == "1d":
+        sql = (
+            f"SELECT COUNT(*) AS v FROM {SOURCE_DAILY_TABLE} "
+            f"WHERE trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}{filt}"
+        )
+    else:
+        end_next = end + timedelta(days=1)
+        sql = (
+            f"SELECT COUNT(*) AS v FROM {SOURCE_MINUTE_TABLE} "
+            f"WHERE bar_time >= {sql_literal(date_to_str(start) + ' 00:00:00')} "
+            f"AND bar_time < {sql_literal(date_to_str(end_next) + ' 00:00:00')}{filt}"
+        )
+    return int(scalar(source_conn, sql) or "0")
+
+
+def source_minute_coverage_rows(source_conn: MysqlConn, start: date, end: date) -> list[dict[str, str]]:
+    try:
+        return query_rows(
+            source_conn,
+            f"""
+SELECT trade_date, row_count
+FROM {SOURCE_COVERAGE_TABLE}
+WHERE period = '1m'
+  AND trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}
+  AND row_count > 0
+ORDER BY trade_date
+""",
+        )
+    except RuntimeError:
+        return []
+
+
+def source_minute_trade_dates(source_conn: MysqlConn, start: date, end: date, symbols: list[str]) -> list[date]:
+    if not symbols:
+        rows = source_minute_coverage_rows(source_conn, start, end)
+        dates = [parse_date(row["trade_date"]) for row in rows if row.get("trade_date")]
+        if dates:
+            return dates
+    days: list[date] = []
+    cur = start
+    while cur <= end:
+        days.append(cur)
+        cur += timedelta(days=1)
+    return days
+
+
+def source_symbols_from_daily(source_conn: MysqlConn, start: date, end: date) -> list[str]:
+    rows = query_rows(
+        source_conn,
+        f"""
+SELECT DISTINCT symbol
+FROM {SOURCE_DAILY_TABLE}
+WHERE trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}
+ORDER BY symbol
+""",
+    )
+    return [row["symbol"] for row in rows if row.get("symbol")]
+
+
+def chunked(items: list[str], size: int) -> Iterable[list[str]]:
+    if size <= 0:
+        size = 50
+    for idx in range(0, len(items), size):
+        yield items[idx : idx + size]
+
+
+def count_source_minute_window(source_conn: MysqlConn, start_dt: datetime, end_dt: datetime, symbols: list[str]) -> int:
+    filt = symbol_filter(symbols)
+    sql = (
+        f"SELECT COUNT(*) AS v FROM {SOURCE_MINUTE_TABLE} "
+        f"WHERE bar_time >= {sql_literal(start_dt.strftime('%Y-%m-%d %H:%M:%S'))} "
+        f"AND bar_time < {sql_literal(end_dt.strftime('%Y-%m-%d %H:%M:%S'))}{filt}"
+    )
+    return int(scalar(source_conn, sql) or "0")
+
+
+def count_tsv_rows(path: Path) -> int:
+    with path.open("rb") as fh:
+        return sum(1 for _ in fh)
+
+
+def build_daily_select(start: date, end: date, symbols: list[str]) -> str:
+    filt = symbol_filter(symbols)
+    return f"""
+SELECT
+  symbol,
+  DATE_FORMAT(trade_date, '%Y-%m-%d'),
+  COALESCE(CAST(`open` AS CHAR), '\\\\N'),
+  COALESCE(CAST(high AS CHAR), '\\\\N'),
+  COALESCE(CAST(low AS CHAR), '\\\\N'),
+  COALESCE(CAST(`close` AS CHAR), '\\\\N'),
+  COALESCE(CAST(pre_close AS CHAR), '\\\\N'),
+  COALESCE(CAST(volume AS CHAR), '\\\\N'),
+  COALESCE(CAST(amount AS CHAR), '\\\\N'),
+  COALESCE(CAST(turnover AS CHAR), '\\\\N'),
+  COALESCE(source_batch_id, '\\\\N'),
+  COALESCE(DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s'), '\\\\N')
+FROM {SOURCE_DAILY_TABLE}
+WHERE trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}{filt}
+""".strip()
+
+
+def build_minute_select(start: date, end: date, symbols: list[str]) -> str:
+    filt = symbol_filter(symbols)
+    end_next = end + timedelta(days=1)
+    return f"""
+SELECT
+  symbol,
+  bar_time,
+  COALESCE(CAST(`open` AS CHAR), '\\\\N'),
+  COALESCE(CAST(high AS CHAR), '\\\\N'),
+  COALESCE(CAST(low AS CHAR), '\\\\N'),
+  COALESCE(CAST(`close` AS CHAR), '\\\\N'),
+  COALESCE(CAST(volume AS CHAR), '\\\\N'),
+  COALESCE(CAST(amount AS CHAR), '\\\\N'),
+  COALESCE(CAST(turnover AS CHAR), '\\\\N'),
+  COALESCE(source_batch_id, '\\\\N'),
+  COALESCE(DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s'), '\\\\N')
+FROM {SOURCE_MINUTE_TABLE} FORCE INDEX(idx_bar_time_symbol)
+WHERE bar_time >= {sql_literal(date_to_str(start) + ' 00:00:00')}
+  AND bar_time < {sql_literal(date_to_str(end_next) + ' 00:00:00')}{filt}
+""".strip()
+
+
+def build_minute_select_window(start_dt: datetime, end_dt: datetime, symbols: list[str]) -> str:
+    filt = symbol_filter(symbols)
+    index_hint = "FORCE INDEX(idx_symbol_bar_time)" if symbols else "FORCE INDEX(idx_bar_time_symbol)"
+    return f"""
+SELECT
+  symbol,
+  bar_time,
+  COALESCE(CAST(`open` AS CHAR), '\\\\N'),
+  COALESCE(CAST(high AS CHAR), '\\\\N'),
+  COALESCE(CAST(low AS CHAR), '\\\\N'),
+  COALESCE(CAST(`close` AS CHAR), '\\\\N'),
+  COALESCE(CAST(volume AS CHAR), '\\\\N'),
+  COALESCE(CAST(amount AS CHAR), '\\\\N'),
+  COALESCE(CAST(turnover AS CHAR), '\\\\N'),
+  COALESCE(source_batch_id, '\\\\N'),
+  COALESCE(DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s'), '\\\\N')
+FROM {SOURCE_MINUTE_TABLE} {index_hint}
+WHERE bar_time >= {sql_literal(start_dt.strftime('%Y-%m-%d %H:%M:%S'))}
+  AND bar_time < {sql_literal(end_dt.strftime('%Y-%m-%d %H:%M:%S'))}{filt}
+""".strip()
+
+
+def load_daily(local_conn: MysqlConn, tsv_path: Path, run_id: str) -> None:
+    path = str(tsv_path).replace("\\", "/").replace("'", "''")
+    local_exec(
+        local_conn,
+        f"""
+LOAD DATA LOCAL INFILE '{path}'
+REPLACE INTO TABLE {LOCAL_DAILY_TABLE}
+CHARACTER SET utf8mb4
+FIELDS TERMINATED BY '\t' ESCAPED BY '\\\\'
+LINES TERMINATED BY '\n'
+(symbol, trade_date, open_price, high_price, low_price, close_price, pre_close_price,
+ volume, amount, turnover, source_batch_id, source_updated_at)
+SET source_table = '{SOURCE_DAILY_TABLE}',
+    sync_run_id = {sql_literal(run_id)},
+    synced_at = CURRENT_TIMESTAMP;
+""",
+    )
+
+
+def load_minute(local_conn: MysqlConn, tsv_path: Path, run_id: str) -> None:
+    path = str(tsv_path).replace("\\", "/").replace("'", "''")
+    local_exec(
+        local_conn,
+        f"""
+LOAD DATA LOCAL INFILE '{path}'
+REPLACE INTO TABLE {LOCAL_MINUTE_TABLE}
+CHARACTER SET utf8mb4
+FIELDS TERMINATED BY '\t' ESCAPED BY '\\\\'
+LINES TERMINATED BY '\n'
+(symbol, @bar_time, open_price, high_price, low_price, close_price,
+ volume, amount, turnover, source_batch_id, source_updated_at)
+SET trade_date = DATE(@bar_time),
+    trade_time = TIME(@bar_time),
+    bar_time = @bar_time,
+    source_table = '{SOURCE_MINUTE_TABLE}',
+    sync_run_id = {sql_literal(run_id)},
+    synced_at = CURRENT_TIMESTAMP;
+""",
+    )
+
+
+def local_loaded_count(local_conn: MysqlConn, period: str, start: date, end: date, run_id: str) -> int:
+    if period == "1d":
+        sql = (
+            f"SELECT COUNT(*) AS v FROM {LOCAL_DAILY_TABLE} "
+            f"WHERE trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))} "
+            f"AND sync_run_id = {sql_literal(run_id)}"
+        )
+    else:
+        sql = (
+            f"SELECT COUNT(*) AS v FROM {LOCAL_MINUTE_TABLE} "
+            f"WHERE trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))} "
+            f"AND sync_run_id = {sql_literal(run_id)}"
+        )
+    return int(scalar(local_conn, sql) or "0")
+
+
+def local_loaded_minute_window(local_conn: MysqlConn, start_dt: datetime, end_dt: datetime, run_id: str) -> int:
+    sql = (
+        f"SELECT COUNT(*) AS v FROM {LOCAL_MINUTE_TABLE} "
+        f"WHERE bar_time >= {sql_literal(start_dt.strftime('%Y-%m-%d %H:%M:%S'))} "
+        f"AND bar_time < {sql_literal(end_dt.strftime('%Y-%m-%d %H:%M:%S'))} "
+        f"AND sync_run_id = {sql_literal(run_id)}"
+    )
+    return int(scalar(local_conn, sql) or "0")
+
+
+def append_daily_to_main(local_conn: MysqlConn, start: date, end: date, run_id: str) -> dict[str, int]:
+    null_ohlc_rows = int(
+        scalar(
+            local_conn,
+            f"""
+SELECT COUNT(*) AS v
+FROM {LOCAL_DAILY_TABLE}
+WHERE sync_run_id = {sql_literal(run_id)}
+  AND trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}
+  AND (open_price IS NULL OR high_price IS NULL OR low_price IS NULL OR close_price IS NULL)
+""",
+        )
+        or "0"
+    )
+    inserted_rows = int(
+        scalar(
+            local_conn,
+            f"""
+INSERT INTO {MAIN_DAILY_TABLE}
+  (trade_date, symbol, open_price, high_price, low_price, close_price, volume, amount)
+SELECT
+  s.trade_date, s.symbol, s.open_price, s.high_price, s.low_price, s.close_price, s.volume, s.amount
+FROM {LOCAL_DAILY_TABLE} s
+LEFT JOIN {MAIN_DAILY_TABLE} d
+  ON d.trade_date = s.trade_date AND d.symbol = s.symbol
+WHERE s.sync_run_id = {sql_literal(run_id)}
+  AND s.trade_date BETWEEN {sql_literal(date_to_str(start))} AND {sql_literal(date_to_str(end))}
+  AND d.symbol IS NULL
+  AND s.open_price IS NOT NULL
+  AND s.high_price IS NOT NULL
+  AND s.low_price IS NOT NULL
+  AND s.close_price IS NOT NULL;
+SELECT ROW_COUNT() AS v;
+""",
+        )
+        or "0"
+    )
+    return {"appended_rows": inserted_rows, "null_ohlc_skipped_rows": null_ohlc_rows}
+
+
+def append_minute_window_to_main(
+    local_conn: MysqlConn,
+    start_dt: datetime,
+    end_dt: datetime,
+    run_id: str,
+    symbols: list[str] | None = None,
+) -> dict[str, int]:
+    filter_values = ",".join(sql_literal(symbol.strip()) for symbol in (symbols or []) if symbol.strip())
+    filt = f"AND symbol IN ({filter_values})" if filter_values else ""
+    source_alias_filt = f"AND s.symbol IN ({filter_values})" if filter_values else ""
+    null_ohlc_rows = int(
+        scalar(
+            local_conn,
+            f"""
+SELECT COUNT(*) AS v
+FROM {LOCAL_MINUTE_TABLE}
+WHERE sync_run_id = {sql_literal(run_id)}
+  AND bar_time >= {sql_literal(start_dt.strftime('%Y-%m-%d %H:%M:%S'))}
+  AND bar_time < {sql_literal(end_dt.strftime('%Y-%m-%d %H:%M:%S'))}
+  {filt}
+  AND (open_price IS NULL OR high_price IS NULL OR low_price IS NULL OR close_price IS NULL)
+""",
+        )
+        or "0"
+    )
+    inserted_rows = int(
+        scalar(
+            local_conn,
+            f"""
+INSERT INTO {MAIN_MINUTE_TABLE}
+  (trade_date, trade_time, symbol, open_price, high_price, low_price, close_price, volume, amount, open_interest)
+SELECT
+  s.trade_date, s.trade_time, s.symbol, s.open_price, s.high_price, s.low_price, s.close_price, s.volume, s.amount, NULL
+FROM {LOCAL_MINUTE_TABLE} s
+LEFT JOIN {MAIN_MINUTE_TABLE} m
+  ON m.trade_date = s.trade_date AND m.trade_time = s.trade_time AND m.symbol = s.symbol
+WHERE s.sync_run_id = {sql_literal(run_id)}
+  AND s.bar_time >= {sql_literal(start_dt.strftime('%Y-%m-%d %H:%M:%S'))}
+  AND s.bar_time < {sql_literal(end_dt.strftime('%Y-%m-%d %H:%M:%S'))}
+  {source_alias_filt}
+  AND m.symbol IS NULL
+  AND s.open_price IS NOT NULL
+  AND s.high_price IS NOT NULL
+  AND s.low_price IS NOT NULL
+  AND s.close_price IS NOT NULL;
+SELECT ROW_COUNT() AS v;
+""",
+        )
+        or "0"
+    )
+    return {"appended_rows": inserted_rows, "null_ohlc_skipped_rows": null_ohlc_rows}
+
+
+def insert_run_log_start(
+    local_conn: MysqlConn,
+    run_id: str,
+    periods: list[str],
+    start: date | None,
+    end: date | None,
+    symbols: list[str],
+    source_conn: MysqlConn,
+) -> None:
+    local_exec(
+        local_conn,
+        f"""
+REPLACE INTO {LOCAL_RUN_TABLE}
+(run_id, started_at, status, periods, start_date, end_date, symbols_scope,
+ source_host, source_database, local_database)
+VALUES (
+  {sql_literal(run_id)},
+  NOW(),
+  'RUNNING',
+  {sql_literal(','.join(periods))},
+  {sql_literal(date_to_str(start)) if start else 'NULL'},
+  {sql_literal(date_to_str(end)) if end else 'NULL'},
+  {sql_literal(','.join(symbols) if symbols else 'ALL_A')},
+  {sql_literal(source_conn.host + ':' + source_conn.port)},
+  {sql_literal(source_conn.database)},
+  DATABASE()
+);
+""",
+    )
+
+
+def update_run_log_finish(
+    local_conn: MysqlConn,
+    run_id: str,
+    status: str,
+    summary: dict[str, object],
+    error_message: str | None = None,
+) -> None:
+    daily = summary.get("periods", {}).get("1d", {}) if isinstance(summary.get("periods"), dict) else {}
+    minute = summary.get("periods", {}).get("1m", {}) if isinstance(summary.get("periods"), dict) else {}
+    local_exec(
+        local_conn,
+        f"""
+UPDATE {LOCAL_RUN_TABLE}
+SET finished_at = NOW(),
+    status = {sql_literal(status)},
+    daily_rows_source = {int(daily.get('source_rows', 0) or 0)},
+    daily_rows_loaded = {int(daily.get('loaded_rows', 0) or 0)},
+    minute_rows_source = {int(minute.get('source_rows', 0) or 0)},
+    minute_rows_loaded = {int(minute.get('loaded_rows', 0) or 0)},
+    summary_json = CAST({sql_literal(json.dumps(summary, ensure_ascii=False))} AS JSON),
+    error_message = {sql_literal(error_message)}
+WHERE run_id = {sql_literal(run_id)};
+""",
+    )
+
+
+def sync_period(
+    source_conn: MysqlConn,
+    local_conn: MysqlConn,
+    period: str,
+    start: date,
+    end: date,
+    symbols: list[str],
+    run_id: str,
+    max_days: int,
+    minute_max_minutes: int,
+    minute_symbols_per_batch: int,
+    dry_run: bool,
+    append_to_main: bool,
+) -> dict[str, object]:
+    if period == "1m":
+        coverage_rows = source_minute_coverage_rows(source_conn, start, end) if not symbols else []
+        source_rows_total = sum(int(row.get("row_count") or 0) for row in coverage_rows) if coverage_rows else 0
+    else:
+        coverage_rows = []
+        source_rows_total = count_source_rows(source_conn, period, start, end, symbols)
+    result: dict[str, object] = {
+        "start_date": date_to_str(start),
+        "end_date": date_to_str(end),
+        "source_rows": source_rows_total,
+        "loaded_rows": 0,
+        "main_appended_rows": 0,
+        "main_append_null_ohlc_skipped_rows": 0,
+        "batches": [],
+    }
+    if dry_run:
+        return result
+
+    loaded_total = 0
+    with tempfile.TemporaryDirectory(prefix=f"kline_{period}_{run_id}_") as tmp:
+        tmp_dir = Path(tmp)
+        if period == "1m":
+            minute_trade_dates = source_minute_trade_dates(source_conn, start, end, symbols)
+            minute_symbols = symbols if symbols else source_symbols_from_daily(source_conn, start, end)
+            symbol_groups = list(chunked(minute_symbols, minute_symbols_per_batch)) if minute_symbols else [[]]
+            result["minute_trade_day_count"] = len(minute_trade_dates)
+            result["minute_symbol_count"] = len(minute_symbols)
+            result["minute_symbols_per_batch"] = minute_symbols_per_batch
+            result["batch_log_truncated_count"] = 0
+            for idx, (batch_start_dt, batch_end_dt, symbol_group_index, symbol_group) in enumerate(
+                (
+                    (window[0], window[1], symbol_group_index, symbol_group)
+                    for trade_day in minute_trade_dates
+                    for window in market_datetime_batches(trade_day, minute_max_minutes)
+                    for symbol_group_index, symbol_group in enumerate(symbol_groups, start=1)
+                ),
+                start=1,
+            ):
+                batch_info = {
+                    "batch_index": idx,
+                    "start_ts": batch_start_dt.strftime("%Y-%m-%d %H:%M:%S"),
+                    "end_ts": batch_end_dt.strftime("%Y-%m-%d %H:%M:%S"),
+                    "symbol_batch_index": symbol_group_index,
+                    "symbol_count": len(symbol_group),
+                    "source_rows": 0,
+                    "loaded_rows": 0,
+                }
+                tsv_path = tmp_dir / f"{period}_{idx:04d}.tsv"
+                export_tsv(source_conn, build_minute_select_window(batch_start_dt, batch_end_dt, symbol_group), tsv_path)
+                batch_source_rows = count_tsv_rows(tsv_path)
+                batch_info["source_rows"] = batch_source_rows
+                if batch_source_rows == 0:
+                    if len(result["batches"]) < 500:
+                        result["batches"].append(batch_info)
+                    else:
+                        result["batch_log_truncated_count"] = int(result["batch_log_truncated_count"]) + 1
+                    continue
+                load_minute(local_conn, tsv_path, run_id)
+                # Symbol-batched windows are disjoint within a run; avoid an
+                # increasingly expensive per-window recount after every symbol
+                # chunk.
+                batch_loaded_rows = batch_source_rows
+                loaded_total += batch_loaded_rows
+                result["source_rows"] = int(result["source_rows"]) + batch_source_rows if not coverage_rows else result["source_rows"]
+                batch_info["loaded_rows"] = batch_loaded_rows
+                if append_to_main:
+                    append_result = append_minute_window_to_main(local_conn, batch_start_dt, batch_end_dt, run_id, symbol_group)
+                    batch_info.update(
+                        {
+                            "main_appended_rows": append_result["appended_rows"],
+                            "main_append_null_ohlc_skipped_rows": append_result["null_ohlc_skipped_rows"],
+                        }
+                    )
+                    result["main_appended_rows"] = int(result["main_appended_rows"]) + append_result["appended_rows"]
+                    result["main_append_null_ohlc_skipped_rows"] = int(
+                        result["main_append_null_ohlc_skipped_rows"]
+                    ) + append_result["null_ohlc_skipped_rows"]
+                if len(result["batches"]) < 500:
+                    result["batches"].append(batch_info)
+                else:
+                    result["batch_log_truncated_count"] = int(result["batch_log_truncated_count"]) + 1
+            result["loaded_rows"] = loaded_total
+            return result
+        for idx, (batch_start, batch_end) in enumerate(date_batches(start, end, max_days), start=1):
+            batch_source_rows = count_source_rows(source_conn, period, batch_start, batch_end, symbols)
+            batch_info: dict[str, object] = {
+                "batch_index": idx,
+                "start_date": date_to_str(batch_start),
+                "end_date": date_to_str(batch_end),
+                "source_rows": batch_source_rows,
+                "loaded_rows": 0,
+            }
+            if batch_source_rows == 0:
+                result["batches"].append(batch_info)
+                continue
+            tsv_path = tmp_dir / f"{period}_{idx:04d}.tsv"
+            select_sql = build_daily_select(batch_start, batch_end, symbols) if period == "1d" else build_minute_select(
+                batch_start, batch_end, symbols
+            )
+            export_tsv(source_conn, select_sql, tsv_path)
+            if period == "1d":
+                load_daily(local_conn, tsv_path, run_id)
+            else:
+                load_minute(local_conn, tsv_path, run_id)
+            batch_loaded_rows = local_loaded_count(local_conn, period, batch_start, batch_end, run_id)
+            loaded_total += batch_loaded_rows
+            batch_info["loaded_rows"] = batch_loaded_rows
+            if append_to_main and period == "1d":
+                append_result = append_daily_to_main(local_conn, batch_start, batch_end, run_id)
+                batch_info.update(
+                    {
+                        "main_appended_rows": append_result["appended_rows"],
+                        "main_append_null_ohlc_skipped_rows": append_result["null_ohlc_skipped_rows"],
+                    }
+                )
+                result["main_appended_rows"] = int(result["main_appended_rows"]) + append_result["appended_rows"]
+                result["main_append_null_ohlc_skipped_rows"] = int(
+                    result["main_append_null_ohlc_skipped_rows"]
+                ) + append_result["null_ohlc_skipped_rows"]
+            result["batches"].append(batch_info)
+    result["loaded_rows"] = loaded_total
+    return result
+
+
+def build_connections(args: argparse.Namespace) -> tuple[MysqlConn, MysqlConn]:
+    mysql_exe = Path(args.mysql_exe)
+    source = MysqlConn(
+        mysql_exe=mysql_exe,
+        host=args.source_host,
+        port=str(args.source_port),
+        user=args.source_user,
+        password=args.source_password or os.environ.get("KLINE_SOURCE_MYSQL_PASSWORD"),
+        database=args.source_database,
+    )
+    local = MysqlConn(
+        mysql_exe=mysql_exe,
+        host=args.local_host,
+        port=str(args.local_port),
+        user=args.local_user,
+        password=args.local_password or os.environ.get("TIANXIA_MYSQL_PASSWORD"),
+        database=args.local_database,
+    )
+    if not source.password:
+        raise RuntimeError("source password is required: pass --source-password or set KLINE_SOURCE_MYSQL_PASSWORD")
+    if not local.password:
+        raise RuntimeError("local password is required: pass --local-password or set TIANXIA_MYSQL_PASSWORD")
+    return source, local
+
+
+def parse_symbols(raw: str | None) -> list[str]:
+    if not raw:
+        return []
+    parts: list[str] = []
+    for item in raw.replace(";", ",").split(","):
+        item = item.strip()
+        if item:
+            parts.append(item)
+    return parts
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--mysql-exe", default=str(DEFAULT_MYSQL_EXE))
+    parser.add_argument("--run-id", default=f"kline_front_incremental_sync_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
+    parser.add_argument("--periods", nargs="+", choices=["1d", "1m"], default=["1d", "1m"])
+    parser.add_argument("--start-date")
+    parser.add_argument("--end-date")
+    parser.add_argument("--symbols", help="comma separated symbols; omit for all A-share rows in the date range")
+    parser.add_argument("--daily-max-days-per-batch", type=int, default=31)
+    parser.add_argument("--minute-max-days-per-batch", type=int, default=3)
+    parser.add_argument("--minute-max-minutes-per-batch", type=int, default=60)
+    parser.add_argument("--minute-symbols-per-batch", type=int, default=50)
+    parser.add_argument(
+        "--append-to-main",
+        action="store_true",
+        help="append successfully synced mirror rows into local main tables without updating existing rows",
+    )
+    parser.add_argument(
+        "--allow-partial-main-append",
+        action="store_true",
+        help="allow --append-to-main when --symbols is provided; otherwise main append requires all-A sync",
+    )
+    parser.add_argument("--dry-run", action="store_true")
+    parser.add_argument("--init-schema-only", action="store_true")
+
+    parser.add_argument("--source-host", default=os.environ.get("KLINE_SOURCE_MYSQL_HOST", "317w7246e5.vicp.fun"))
+    parser.add_argument("--source-port", default=os.environ.get("KLINE_SOURCE_MYSQL_PORT", "50176"))
+    parser.add_argument("--source-user", default=os.environ.get("KLINE_SOURCE_MYSQL_USER", "root"))
+    parser.add_argument("--source-password")
+    parser.add_argument("--source-database", default=os.environ.get("KLINE_SOURCE_MYSQL_DB", "trading_xuntou"))
+
+    parser.add_argument("--local-host", default=os.environ.get("TIANXIA_MYSQL_HOST", "127.0.0.1"))
+    parser.add_argument("--local-port", default=os.environ.get("TIANXIA_MYSQL_PORT", "3306"))
+    parser.add_argument("--local-user", default=os.environ.get("TIANXIA_MYSQL_USER", "root"))
+    parser.add_argument("--local-password")
+    parser.add_argument("--local-database", default=os.environ.get("TIANXIA_MYSQL_DB", "tianxia"))
+    return parser.parse_args()
+
+
+def main() -> None:
+    args = parse_args()
+    symbols = parse_symbols(args.symbols)
+    if args.append_to_main and symbols and not args.allow_partial_main_append:
+        raise RuntimeError(
+            "--append-to-main with --symbols would create a partial-date main table. "
+            "Use --allow-partial-main-append only for explicit repair tasks."
+        )
+    source_conn, local_conn = build_connections(args)
+    create_local_schema(local_conn)
+    if args.init_schema_only:
+        print(f"schema_ready=1 run_id={args.run_id}")
+        return
+
+    run_start = datetime.now()
+    insert_run_log_start(local_conn, args.run_id, args.periods, None, None, symbols, source_conn)
+    summary: dict[str, object] = {
+        "run_id": args.run_id,
+        "started_at": run_start.isoformat(timespec="seconds"),
+        "dry_run": int(args.dry_run),
+        "source": {
+            "host": source_conn.host,
+            "port": source_conn.port,
+            "database": source_conn.database,
+            "tables": {"1d": SOURCE_DAILY_TABLE, "1m": SOURCE_MINUTE_TABLE},
+            "write_policy": "READ_ONLY_SOURCE_NO_HTTP_BACKFILL",
+        },
+        "local": {
+            "host": local_conn.host,
+            "port": local_conn.port,
+            "database": local_conn.database,
+            "tables": {
+                "1d": LOCAL_DAILY_TABLE,
+                "1m": LOCAL_MINUTE_TABLE,
+                "run_log": LOCAL_RUN_TABLE,
+                "main_1d": MAIN_DAILY_TABLE,
+                "main_1m": MAIN_MINUTE_TABLE,
+            },
+            "write_policy": "MIRROR_TABLES_PLUS_APPEND_ONLY_MAIN" if args.append_to_main else "MIRROR_TABLES_ONLY",
+        },
+        "symbols_scope": symbols if symbols else "ALL_A",
+        "periods": {},
+    }
+    status = "SUCCESS"
+    error_message = None
+    try:
+        resolved_starts: list[date] = []
+        resolved_ends: list[date] = []
+        for period in args.periods:
+            start = resolve_start_date(local_conn, period, args.start_date)
+            end = resolve_end_date(source_conn, period, args.end_date)
+            resolved_starts.append(start)
+            resolved_ends.append(end)
+            if start > end:
+                summary["periods"][period] = {
+                    "start_date": date_to_str(start),
+                    "end_date": date_to_str(end),
+                    "source_rows": 0,
+                    "loaded_rows": 0,
+                    "status": "NOOP_LOCAL_ALREADY_UP_TO_DATE_OR_SOURCE_EMPTY",
+                }
+                continue
+            max_days = args.daily_max_days_per_batch if period == "1d" else args.minute_max_days_per_batch
+            period_result = sync_period(
+                source_conn,
+                local_conn,
+                period,
+                start,
+                end,
+                symbols,
+                args.run_id,
+                max_days,
+                args.minute_max_minutes_per_batch,
+                args.minute_symbols_per_batch,
+                args.dry_run,
+                args.append_to_main and not args.dry_run,
+            )
+            period_result["status"] = "DRY_RUN" if args.dry_run else "SYNCED"
+            summary["periods"][period] = period_result
+        summary["resolved_start_date"] = date_to_str(min(resolved_starts)) if resolved_starts else ""
+        summary["resolved_end_date"] = date_to_str(max(resolved_ends)) if resolved_ends else ""
+    except Exception as exc:
+        status = "FAILED"
+        error_message = str(exc)
+        summary["error_message"] = error_message
+        raise
+    finally:
+        summary["finished_at"] = datetime.now().isoformat(timespec="seconds")
+        try:
+            update_run_log_finish(local_conn, args.run_id, status, summary, error_message)
+        except Exception:
+            if status != "FAILED":
+                raise
+        out_dir = RESULT_ROOT / args.run_id
+        out_dir.mkdir(parents=True, exist_ok=True)
+        (out_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+        print(json.dumps(summary, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/restore-sessions/00-start-codex-app-server-4570.bat b/scripts/restore-sessions/00-start-codex-app-server-4570.bat
new file mode 100644
index 0000000..9318ea0
--- /dev/null
+++ b/scripts/restore-sessions/00-start-codex-app-server-4570.bat
@@ -0,0 +1,47 @@
+@echo off
+setlocal
+chcp 65001 >nul
+
+REM MB-X registry-aware Codex app-server start helper.
+set "WORKSPACE_ROOT=D:\manage_system"
+set "MBX_SRC=D:\manage_system\project-mbx-core\src"
+set "APP_SERVER_URL=ws://127.0.0.1:4570"
+set "PYTHONPATH=%MBX_SRC%;%PYTHONPATH%"
+
+echo [MB-X] Preflight: checking python and Codex CLI in this cmd/BAT environment.
+echo [MB-X] PATH=%PATH%
+where python >nul 2>nul
+if errorlevel 1 (
+  echo [MB-X][ERROR] python not found in PATH.
+  echo [MB-X][NEXT] Add Python to PATH or run this script from a Python-enabled shell.
+  pause
+  exit /b 1
+)
+for /f "delims=" %%I in ('where python 2^>nul') do if not defined MBX_PYTHON_EXE set "MBX_PYTHON_EXE=%%I"
+echo [MB-X] python=%MBX_PYTHON_EXE%
+set "MBX_CODEX_EXE="
+for /f "delims=" %%I in ('where codex.cmd 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex.exe 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE (
+  echo [MB-X][ERROR] Codex CLI not found in this cmd/BAT PATH. remote TUI cannot start.
+  echo [MB-X][NEXT] Confirm `codex.cmd --version` works in the same shell, add the npm/Codex bin directory to PATH, or update the workspace mbx.yaml provider command to an absolute codex.cmd/codex.exe path.
+  echo [MB-X][DIAG] where codex:
+  where codex
+  echo [MB-X][DIAG] where codex.cmd:
+  where codex.cmd
+  pause
+  exit /b 5
+)
+echo [MB-X] codex=%MBX_CODEX_EXE%
+echo [MB-X] where codex.cmd:
+where codex.cmd
+echo [MB-X] where codex:
+where codex
+
+cd /d "%WORKSPACE_ROOT%"
+echo [MB-X] Starting or reusing Codex app-server at %APP_SERVER_URL%
+python -m mbx.cli codex-server start --listen "%APP_SERVER_URL%"
+echo.
+echo [MB-X] If status is already running, keep that server and run one restore-*.bat script.
+pause
diff --git a/scripts/restore-sessions/README.md b/scripts/restore-sessions/README.md
new file mode 100644
index 0000000..e588604
--- /dev/null
+++ b/scripts/restore-sessions/README.md
@@ -0,0 +1,42 @@
+# MB-X session restore scripts
+
+Generated by `mbx session restore-script generate`.
+
+## Scope
+
+These scripts are registry-aware wrappers around MB-X CLI. They intentionally do **not** hardcode provider_session_id/thread_id. Runtime session ids are read from the current MB-X workspace/project configuration when the script runs.
+
+App-server URL: `ws://127.0.0.1:4570`
+
+Targets:
+
+- `management.admin` -> `open-management-admin.bat` / `restore-management-admin.bat`
+- `management.observer` -> `open-management-observer.bat` / `restore-management-observer.bat`
+
+## Managed Session Reuse
+
+Multiple role ids can intentionally point to the same managed Codex window. Running any role wrapper in the same group opens or restores that same registered thread.
+
+- `management.admin`: `management.admin`
+- `management.observer`: `management.observer`
+
+## Usage
+
+1. Run `00-start-codex-app-server-4570.bat` if the Codex app-server is not running.
+2. The scripts preflight both `python` and Codex CLI (`codex.cmd` / `codex.exe` / `codex`) in the same cmd/BAT PATH used to launch the wrapper. If Codex is missing, fix PATH or set the workspace provider command to an absolute executable path before retrying.
+3. Run `open-<role>.bat` first for non-destructive open/reuse. It can recover stale/missing PID cases by resuming the existing thread, but it will not terminate an alive recorded PID.
+4. If status says PID is alive but the visible window is still missing, run `restore-<role>.bat`; it asks for `REOPEN` before calling `mbx session reopen --terminate-existing`.
+5. After open/restore, verify the visible Codex window contains the old chat history. `session history --contains` is useful evidence, but it is not a substitute for visible-window proof.
+6. Regenerate these scripts after role/session binding, project root, app-server URL, or MB-X source path changes:
+
+```powershell
+python -m mbx.cli session restore-script generate --include-management
+```
+
+## Risk boundary
+
+`restore-*.bat` may terminate the recorded old remote TUI process. For `management.admin` and `management.observer`, real destructive close/reopen validation is a management high-risk action and must be recorded by the management/project workflow.
+
+Formal manual recovery guide: `doc/operation/MB-X会话窗口手动恢复指南.md`
+
+Workspace root used by generated wrappers: `D:\manage_system`
diff --git a/scripts/restore-sessions/open-management-admin.bat b/scripts/restore-sessions/open-management-admin.bat
new file mode 100644
index 0000000..72d9ff0
--- /dev/null
+++ b/scripts/restore-sessions/open-management-admin.bat
@@ -0,0 +1,66 @@
+@echo off
+setlocal
+chcp 65001 >nul
+
+REM MB-X non-destructive managed open helper.
+REM role: management.admin
+REM managed_session_key: management.admin
+REM Use this first when the recorded PID is stale/missing or when you only want safe open/reuse.
+REM It resumes the existing provider_session_id/thread_id and never terminates an alive remote TUI.
+REM If a PID is alive but the visible window is missing, use restore-*.bat instead.
+set "WORKDIR=D:\manage_system"
+set "MBX_SRC=D:\manage_system\project-mbx-core\src"
+set "PYTHONPATH=%MBX_SRC%;%PYTHONPATH%"
+
+echo [MB-X] Preflight: checking python and Codex CLI in this cmd/BAT environment.
+echo [MB-X] PATH=%PATH%
+where python >nul 2>nul
+if errorlevel 1 (
+  echo [MB-X][ERROR] python not found in PATH.
+  echo [MB-X][NEXT] Add Python to PATH or run this script from a Python-enabled shell.
+  pause
+  exit /b 1
+)
+for /f "delims=" %%I in ('where python 2^>nul') do if not defined MBX_PYTHON_EXE set "MBX_PYTHON_EXE=%%I"
+echo [MB-X] python=%MBX_PYTHON_EXE%
+set "MBX_CODEX_EXE="
+for /f "delims=" %%I in ('where codex.cmd 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex.exe 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE (
+  echo [MB-X][ERROR] Codex CLI not found in this cmd/BAT PATH. remote TUI cannot start.
+  echo [MB-X][NEXT] Confirm `codex.cmd --version` works in the same shell, add the npm/Codex bin directory to PATH, or update the workspace mbx.yaml provider command to an absolute codex.cmd/codex.exe path.
+  echo [MB-X][DIAG] where codex:
+  where codex
+  echo [MB-X][DIAG] where codex.cmd:
+  where codex.cmd
+  pause
+  exit /b 5
+)
+echo [MB-X] codex=%MBX_CODEX_EXE%
+echo [MB-X] where codex.cmd:
+where codex.cmd
+echo [MB-X] where codex:
+where codex
+
+cd /d "%WORKDIR%"
+echo [MB-X] Target status before open for management.admin
+python -m mbx.cli session status --management
+echo.
+echo [MB-X] Opening/reusing visible session for management.admin
+echo [MB-X] Use this script for stale/missing PID. Use restore-*.bat only when an alive recorded PID has no visible window.
+echo [MB-X] Command: python -m mbx.cli session open --management
+python -m mbx.cli session open --management
+set "MBX_EXIT=%ERRORLEVEL%"
+if not "%MBX_EXIT%"=="0" (
+  echo [MB-X][ERROR] session open failed with exit code %MBX_EXIT%.
+  echo [MB-X][NEXT] Read the error details above. If app-server is alive but remote_tui_start_failed reports missing executable, fix Codex CLI PATH/codex.cmd first. If a PID is alive but no window is visible, use restore-*.bat only with recorded approval.
+  pause
+  exit /b %MBX_EXIT%
+)
+echo.
+echo [MB-X] Target status after open for management.admin
+python -m mbx.cli session status --management
+echo.
+echo [MB-X] Verify the visible Codex window shows old chat history. If it does not, run restore-*.bat with recorded approval.
+pause
diff --git a/scripts/restore-sessions/open-management-observer.bat b/scripts/restore-sessions/open-management-observer.bat
new file mode 100644
index 0000000..9ca9bce
--- /dev/null
+++ b/scripts/restore-sessions/open-management-observer.bat
@@ -0,0 +1,66 @@
+@echo off
+setlocal
+chcp 65001 >nul
+
+REM MB-X non-destructive managed open helper.
+REM role: management.observer
+REM managed_session_key: management.observer
+REM Use this first when the recorded PID is stale/missing or when you only want safe open/reuse.
+REM It resumes the existing provider_session_id/thread_id and never terminates an alive remote TUI.
+REM If a PID is alive but the visible window is missing, use restore-*.bat instead.
+set "WORKDIR=D:\manage_system"
+set "MBX_SRC=D:\manage_system\project-mbx-core\src"
+set "PYTHONPATH=%MBX_SRC%;%PYTHONPATH%"
+
+echo [MB-X] Preflight: checking python and Codex CLI in this cmd/BAT environment.
+echo [MB-X] PATH=%PATH%
+where python >nul 2>nul
+if errorlevel 1 (
+  echo [MB-X][ERROR] python not found in PATH.
+  echo [MB-X][NEXT] Add Python to PATH or run this script from a Python-enabled shell.
+  pause
+  exit /b 1
+)
+for /f "delims=" %%I in ('where python 2^>nul') do if not defined MBX_PYTHON_EXE set "MBX_PYTHON_EXE=%%I"
+echo [MB-X] python=%MBX_PYTHON_EXE%
+set "MBX_CODEX_EXE="
+for /f "delims=" %%I in ('where codex.cmd 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex.exe 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE (
+  echo [MB-X][ERROR] Codex CLI not found in this cmd/BAT PATH. remote TUI cannot start.
+  echo [MB-X][NEXT] Confirm `codex.cmd --version` works in the same shell, add the npm/Codex bin directory to PATH, or update the workspace mbx.yaml provider command to an absolute codex.cmd/codex.exe path.
+  echo [MB-X][DIAG] where codex:
+  where codex
+  echo [MB-X][DIAG] where codex.cmd:
+  where codex.cmd
+  pause
+  exit /b 5
+)
+echo [MB-X] codex=%MBX_CODEX_EXE%
+echo [MB-X] where codex.cmd:
+where codex.cmd
+echo [MB-X] where codex:
+where codex
+
+cd /d "%WORKDIR%"
+echo [MB-X] Target status before open for management.observer
+python -m mbx.cli session status --management-observer
+echo.
+echo [MB-X] Opening/reusing visible session for management.observer
+echo [MB-X] Use this script for stale/missing PID. Use restore-*.bat only when an alive recorded PID has no visible window.
+echo [MB-X] Command: python -m mbx.cli session open --management-observer
+python -m mbx.cli session open --management-observer
+set "MBX_EXIT=%ERRORLEVEL%"
+if not "%MBX_EXIT%"=="0" (
+  echo [MB-X][ERROR] session open failed with exit code %MBX_EXIT%.
+  echo [MB-X][NEXT] Read the error details above. If app-server is alive but remote_tui_start_failed reports missing executable, fix Codex CLI PATH/codex.cmd first. If a PID is alive but no window is visible, use restore-*.bat only with recorded approval.
+  pause
+  exit /b %MBX_EXIT%
+)
+echo.
+echo [MB-X] Target status after open for management.observer
+python -m mbx.cli session status --management-observer
+echo.
+echo [MB-X] Verify the visible Codex window shows old chat history. If it does not, run restore-*.bat with recorded approval.
+pause
diff --git a/scripts/restore-sessions/restore-management-admin.bat b/scripts/restore-sessions/restore-management-admin.bat
new file mode 100644
index 0000000..97250d1
--- /dev/null
+++ b/scripts/restore-sessions/restore-management-admin.bat
@@ -0,0 +1,66 @@
+@echo off
+setlocal
+chcp 65001 >nul
+
+REM MB-X destructive visible reopen helper.
+REM role: management.admin
+REM managed_session_key: management.admin
+REM It terminates only the recorded remote_tui_pid if alive, then resumes the existing thread through app-server.
+REM It does not delete runtime queues and does not create a new provider session/thread.
+set "WORKDIR=D:\manage_system"
+set "MBX_SRC=D:\manage_system\project-mbx-core\src"
+set "PYTHONPATH=%MBX_SRC%;%PYTHONPATH%"
+
+echo [MB-X] Preflight: checking python and Codex CLI in this cmd/BAT environment.
+echo [MB-X] PATH=%PATH%
+where python >nul 2>nul
+if errorlevel 1 (
+  echo [MB-X][ERROR] python not found in PATH.
+  echo [MB-X][NEXT] Add Python to PATH or run this script from a Python-enabled shell.
+  pause
+  exit /b 1
+)
+for /f "delims=" %%I in ('where python 2^>nul') do if not defined MBX_PYTHON_EXE set "MBX_PYTHON_EXE=%%I"
+echo [MB-X] python=%MBX_PYTHON_EXE%
+set "MBX_CODEX_EXE="
+for /f "delims=" %%I in ('where codex.cmd 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex.exe 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE (
+  echo [MB-X][ERROR] Codex CLI not found in this cmd/BAT PATH. remote TUI cannot start.
+  echo [MB-X][NEXT] Confirm `codex.cmd --version` works in the same shell, add the npm/Codex bin directory to PATH, or update the workspace mbx.yaml provider command to an absolute codex.cmd/codex.exe path.
+  echo [MB-X][DIAG] where codex:
+  where codex
+  echo [MB-X][DIAG] where codex.cmd:
+  where codex.cmd
+  pause
+  exit /b 5
+)
+echo [MB-X] codex=%MBX_CODEX_EXE%
+echo [MB-X] where codex.cmd:
+where codex.cmd
+echo [MB-X] where codex:
+where codex
+
+cd /d "%WORKDIR%"
+echo [MB-X] Restoring visible session for management.admin
+echo [MB-X][RISK] This may terminate the recorded old remote TUI PID if it is still alive.
+echo [MB-X][RISK] For management roles, record this as a high-risk management action if used for real validation.
+set /p MBX_CONFIRM=Type REOPEN to continue: 
+if /I not "%MBX_CONFIRM%"=="REOPEN" (
+  echo [MB-X] Cancelled.
+  exit /b 2
+)
+echo [MB-X] Command: python -m mbx.cli session reopen --management --terminate-existing
+python -m mbx.cli session reopen --management --terminate-existing
+set "MBX_EXIT=%ERRORLEVEL%"
+if not "%MBX_EXIT%"=="0" (
+  echo [MB-X][ERROR] session reopen failed with exit code %MBX_EXIT%.
+  pause
+  exit /b %MBX_EXIT%
+)
+echo.
+python -m mbx.cli session status --management
+echo.
+echo [MB-X] Verify the visible window shows the old chat history; do not use history/read alone as window proof.
+pause
diff --git a/scripts/restore-sessions/restore-management-observer.bat b/scripts/restore-sessions/restore-management-observer.bat
new file mode 100644
index 0000000..4eb415b
--- /dev/null
+++ b/scripts/restore-sessions/restore-management-observer.bat
@@ -0,0 +1,66 @@
+@echo off
+setlocal
+chcp 65001 >nul
+
+REM MB-X destructive visible reopen helper.
+REM role: management.observer
+REM managed_session_key: management.observer
+REM It terminates only the recorded remote_tui_pid if alive, then resumes the existing thread through app-server.
+REM It does not delete runtime queues and does not create a new provider session/thread.
+set "WORKDIR=D:\manage_system"
+set "MBX_SRC=D:\manage_system\project-mbx-core\src"
+set "PYTHONPATH=%MBX_SRC%;%PYTHONPATH%"
+
+echo [MB-X] Preflight: checking python and Codex CLI in this cmd/BAT environment.
+echo [MB-X] PATH=%PATH%
+where python >nul 2>nul
+if errorlevel 1 (
+  echo [MB-X][ERROR] python not found in PATH.
+  echo [MB-X][NEXT] Add Python to PATH or run this script from a Python-enabled shell.
+  pause
+  exit /b 1
+)
+for /f "delims=" %%I in ('where python 2^>nul') do if not defined MBX_PYTHON_EXE set "MBX_PYTHON_EXE=%%I"
+echo [MB-X] python=%MBX_PYTHON_EXE%
+set "MBX_CODEX_EXE="
+for /f "delims=" %%I in ('where codex.cmd 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex.exe 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE for /f "delims=" %%I in ('where codex 2^>nul') do if not defined MBX_CODEX_EXE set "MBX_CODEX_EXE=%%I"
+if not defined MBX_CODEX_EXE (
+  echo [MB-X][ERROR] Codex CLI not found in this cmd/BAT PATH. remote TUI cannot start.
+  echo [MB-X][NEXT] Confirm `codex.cmd --version` works in the same shell, add the npm/Codex bin directory to PATH, or update the workspace mbx.yaml provider command to an absolute codex.cmd/codex.exe path.
+  echo [MB-X][DIAG] where codex:
+  where codex
+  echo [MB-X][DIAG] where codex.cmd:
+  where codex.cmd
+  pause
+  exit /b 5
+)
+echo [MB-X] codex=%MBX_CODEX_EXE%
+echo [MB-X] where codex.cmd:
+where codex.cmd
+echo [MB-X] where codex:
+where codex
+
+cd /d "%WORKDIR%"
+echo [MB-X] Restoring visible session for management.observer
+echo [MB-X][RISK] This may terminate the recorded old remote TUI PID if it is still alive.
+echo [MB-X][RISK] For management roles, record this as a high-risk management action if used for real validation.
+set /p MBX_CONFIRM=Type REOPEN to continue: 
+if /I not "%MBX_CONFIRM%"=="REOPEN" (
+  echo [MB-X] Cancelled.
+  exit /b 2
+)
+echo [MB-X] Command: python -m mbx.cli session reopen --management-observer --terminate-existing
+python -m mbx.cli session reopen --management-observer --terminate-existing
+set "MBX_EXIT=%ERRORLEVEL%"
+if not "%MBX_EXIT%"=="0" (
+  echo [MB-X][ERROR] session reopen failed with exit code %MBX_EXIT%.
+  pause
+  exit /b %MBX_EXIT%
+)
+echo.
+python -m mbx.cli session status --management-observer
+echo.
+echo [MB-X] Verify the visible window shows the old chat history; do not use history/read alone as window proof.
+pause
diff --git "a/\345\205\250\345\261\200\350\247\204\350\214\203.md" "b/\345\205\250\345\261\200\350\247\204\350\214\203.md"
index 7895779..a2f8f7d 100644
--- "a/\345\205\250\345\261\200\350\247\204\350\214\203.md"
+++ "b/\345\205\250\345\261\200\350\247\204\350\214\203.md"
@@ -555,7 +555,23 @@
 
 半成品、空壳文件、失败包不得被下游当成正式完成产物。
 
-## 17. 汇报规范
+## 17. Codex 会话上下文保护规范
+
+角色窗口处理任务时,必须遵守“主体内容落文档,窗口只展示摘要和证据入口”的原则。
+
+禁止将大文件、大目录、大量搜索结果、批量日志或批量图片内容直接回显到 Codex 会话窗口。
+
+具体要求:
+
+1. 不得直接使用 `Get-Content -Raw` 将大文件完整输出到窗口。
+2. `rg`、`Select-String`、日志检索、代码检索等操作必须限制输出行数;如需全量结果,应写入 `tmp/`、readout 文件、证据包、审计附件或正式结果文档。
+3. 不得对大目录执行无上限递归列表并直接回显;目录扫描结果应输出到文件,只在窗口展示摘要、数量、关键路径和异常项。
+4. 图片证据优先记录图片路径、manifest、缩略图或抽样结果;避免批量 `view_image` 把大图片载荷写入会话历史。
+5. 大结果应写入 `tmp/`、evidence package、readout 文件、审计附件或正式结果文档。
+6. 角色窗口和 MB-X 消息只展示摘要、关键行、文件路径、证据入口、结论和期望动作,不复制大段正文。
+7. 处理 `context window full` 或上下文耗尽时,不得未经确认直接创建新 thread;应优先执行备份、摘要、裁剪、审计记录和原 thread 恢复。
+
+## 18. 汇报规范
 
 对用户汇报时,默认先说结论。
 
@@ -572,7 +588,7 @@
 
 不得把“代码能跑”“测试通过”“summary 好看”直接说成“事项通过”。
 
-## 18. 禁止事项
+## 19. 禁止事项
 
 禁止:
 
@@ -587,7 +603,7 @@
 9. 不记录来源聊天就开始执行复杂事项。
 10. 问题未复验就关闭。
 
-## 19. 变更规则
+## 20. 变更规则
 
 修改本文件时必须遵守:
 
@@ -597,7 +613,7 @@
 4. 不得因为某个项目的临时问题,把全局规范改得过重。
 5. 如果只是执行者个人失误,不应上升成全局硬规则。
 
-## 20. 当前核心口径摘要
+## 21. 当前核心口径摘要
 
 1. 体系是插件,用不用由项目管理员决定。
 2. 项目体系和开发体系默认随项目创建;其他体系由项目管理员按需启用。
@@ -612,3 +628,4 @@
 11. 具体项目内容不得污染全局或 common。
 12. 项目本地规范可以补充规则和设计本地流程;已审核通过的本地流程优先执行,但不能削弱全局规范和 common 体系硬约束。
 13. 全局公共数据入口是 `全局存储体系.md` 和 `全局数据格式.md`;读数据先找数据源,写字段先核 schema。
+14. Codex 角色窗口只展示摘要和证据入口,大输出必须落入文件、证据包或正式文档;上下文耗尽时先备份、摘要、裁剪和审计,不得未经确认直接创建新 thread。

--
Gitblit v1.9.3