"""FR-167 拆卡底表：design.md §6、Notion spec、工作坊四欄都從這張表產，改一處三處一致。

每張卡：id／階段／批／repo／標題／reuse-extend-new／改哪些既有檔／新開哪些檔／依賴／驗收／model／effort／理由。
批（Stage）＝能同時跑的那一組：不改同一檔、不依賴對方產出、不搶同一外部資源。階段只是分組標籤。
repo 值：main（BE）、fe、jedi-packages、agent、license-center、update-center（本案新 repo，本機 ~/Projects/Billows/Audit-Manager/update-center、遠端 gitlab.com/auditmanager/update-center、branch main；開卡前登記 workshop.toml）。

兩期分開：
- 開發期（工作坊無人跑）：每張卡的 `accept` 只寫**小弟在本機／DEV 就驗得完**的事（shellcheck、bats、pytest、wrangler dev、DEV 8003、本機 docker）。
  不碰 190、不跑 pipeline、不需要 Cloudflare 帳號、不需要 PROD 印章。
- 測試期（決策者＋首腦互動）：`e2e` 欄是這張卡在 190 上的真實情境，照 design §7 由首腦操作、決策者看畫面；開發期不做。
- 決策者 2026-10-29 授權小弟：① 可 push 自己的 ws branch 到 GitLab；② 套件要發版就發 **beta 版號**（PEP 440：`1.10.0b1`），正式版號留給結案卡。
  push 與發 beta 以外的一切仍照 workshop.md（不切 branch、不動 main、不碰 STG／POC）。
- BE 小弟起服務一律 `PORT=8003 python main.py`（主資料夾的 8000 是決策者在用的 DEV）；FE 小弟 `VITE_API_URL` 指 8003；禁跑 `poetry install`／裸 `poetry update`（會把 path／beta 安裝換掉，FR-166 踩過兩次）。
- 版本實況（2026-10-29 查）：190 跑 1.26.0；188 只剩 1.25.1、1.26.0 兩包。測試期直升情境用 1.26.0→新版；跨版兩段用 1.25.1 裝機、1.26.0 標必經。

登記型檔只有 owner 能碰：
- `scripts/installer/guidantai`、`scripts/installer/install.sh`：owner P1-1（一次開好 lib/ 各步驟空殼與子命令派送，之後的卡只填自己的 lib 檔）
- `config/app_modules.py`、`di_containers/`：owner P3-2a
- `core/plugins/remote_agent.py`：P1-3b 接 dispatch_gate、P4-2 接 agent update（不同批）
- evidence-agent `core/enroll.py`：owner P4-3（本案只有這一張碰）
"""

CARDS = [
# ───────── 第一期 升級變安全（P1） ─────────
dict(id="P1-1", phase="P1", stage=1, repo="main", model="opus", effort="high",
  title="升級骨架：guidantai 加 upgrade／recover 子命令、先換成新版的自己（D30）、lib/ 步驟空殼、進度檔讀寫、每步時限框架",
  ren="extend：`guidantai` 派送段加兩個子命令、`install.sh` 的 `run_upgrade` 改成逐步呼叫 `lib/upgrade_runner.sh`，兩支都超標只准一行呼叫。**登記型檔集中**：這張卡一次開好 `scripts/installer/lib/` 下所有步驟檔的空殼（preflight／drain／db_copy／apply_on_copy／health／switch／rollback／boot_check／cleanup／order／release_keys／systemd_units），後面的卡只填自己的檔",
  edit=["scripts/installer/guidantai 1562（派送兩行、source lib）", "scripts/installer/install.sh 3021（run_upgrade 改呼叫 runner；install_ops_cli 連 lib/ 一起裝）"],
  new=["scripts/installer/lib/upgrade_runner.sh（step／timeout／寫進度）", "scripts/installer/lib/progress.sh（upgrade-progress.json 讀寫，只有這裡寫）", "scripts/installer/lib/self_replace.sh（D30：從已驗包取 guidantai＋lib 換掉 /usr/local/bin 後 exec）", "scripts/installer/lib/upgrade_{preflight,drain,db_copy,apply_on_copy,health,switch,rollback,boot_check,cleanup,order}.sh（空殼）", "scripts/installer/lib/{release_keys,systemd_units}.sh（空殼）"],
  deps=[], why="整個第一期的骨架與登記檔 owner；時限框架與進度檔格式一次定，後面十張卡照它填",
  e2e='190：`sudo guidantai upgrade <包>` 先把自己換成包內版本（`guidantai --version` 是新版）再往下跑',
  accept='`lib/` 每支 `bash -n`＋shellcheck 綠；bats：self_replace 對一個假包目錄能換掉 `--root` 指定的暫存 bin 目錄並 exec、簽章驗不過時暫存目錄一個位元組都沒變；runner 對一支 `sleep 5` 的假步驟給 2 秒時限 → 非零、進度檔 `status=timeout`；進度檔格式照 design §5.1（jq 驗欄位）；`install.sh` 與 `guidantai` 行數不增加'),
dict(id="P1-9a", phase="P1", stage=1, repo="main", model="opus", effort="medium",
  title="出包：產未簽的 release.json（版號、主版號、雜湊、大小、可直升起始版、是否含安全修正）、可直升起始版由 migration 必經標記自動算、包內帶 Agent compose 交付包",
  ren="extend：`build_bundle.sh` 的 bundle-info 段加 release.json；必經標記讀 `scripts/sql/manifest.tsv` 新欄（D17 三種情況由人標、腳本算）",
  edit=["scripts/build/build_bundle.sh 408–430", "scripts/sql/manifest.tsv（加 min_base 欄說明列）"],
  new=["scripts/build/release_json.sh"],
  deps=[], why="release.json 是更新站、CLI、BE 三邊共同契約，先定",
  e2e='188 跑 build_bundle 後包內有 `release.json`，sha256 與 `.sha256` 檔一致，包內有 Agent compose 交付包',
  accept='本機對一個假的 bundle 目錄跑 `release_json.sh`：欄位齊、sha256 與 `.sha256` 檔一致；manifest.tsv 標一支必經後 `min_base_version` 變那版、沒標時是 1.0.0；bundle-info.txt 的「1.0.0 以上」改成算出來的值；shellcheck 綠。**不在 188 出包**'),
dict(id="P1-3a", phase="P1", stage=1, repo="jedi-packages", model="opus", effort="medium",
  title="jedi-remote-agent 心跳契約一次改完：dispatch_gate port（停派新任務）、下發 target_version 與 Agent 包資訊、收 Agent 升級結果欄",
  ren="extend：`RemoteAgentAdapters` 加 `dispatch_gate`（None＝照常）與 `update_extra`；心跳收 `upgrade_result` 照 `heartbeat_console.py` 白名單收法；不動任務邏輯",
  edit=["jedi_remote_agent/app/service/agent_enrollment_service.py（心跳回應組裝、收欄位）", "jedi_remote_agent/plugin/adapters.py（兩個 port）"],
  new=[], deps=[], why="Agent 與主系統的對外約定，一次定完；第一期（停派）與第四期（升級）都靠它",
  accept='`dispatch_gate` 回 False 時心跳不帶 pending 任務但照收結果（test）；回應含 `update: {target_version, bundle_sha256, size}` 或 null；收到 `upgrade_result` 寫 `remote_agents.last_upgrade_*`；既有 tests 綠；**版號 bump 到 `1.10.0b1` 並 `poetry publish -r nexus`（決策者授權發 beta），回寫寫明版號**'),
dict(id="P3-1", phase="P3", stage=1, repo="main", model="sonnet", effort="medium",
  title="能力點 system.product-update.read／update、更新中心選單路由、預設角色授權（照 FR-134 vulndb 頁 migration）",
  ren="extend：`host_capabilities.py` 加兩顆；migration 照 `2026-10-10-fr134-vulndb-settings-page.sql` 形狀",
  edit=["core/host_capabilities.py 199 附近"], new=["scripts/sql/2026-xx-xx-fr167-product-update-capabilities.sql"],
  deps=[], why="形狀全有先例，機械",
  accept="DEV 套後 `capabilities` 有兩顆、`ui_routes` 有 `product-update`、系統管理員角色帶 update；FR-132 守衛測試綠；回報「出貨基線待重產」"),
dict(id="P1-8", phase="P1", stage=1, repo="fe", model="opus", effort="medium",
  title="前門維護頁：nginx 後端連不上改送 maintenance.html，頁面每 3 秒讀唯讀掛進來的進度檔畫步驟與時限（雛形 ②）",
  ren="extend：`nginx.onprem.conf` 加 `error_page 502 503 =503 /maintenance.html` 與 `/upgrade-progress.json` alias；頁面純 HTML＋少量 JS，不用框架、不接 API",
  edit=["nginx.onprem.conf"], new=["public/maintenance.html"],
  deps=[], why="獨立於主系統、要在後端停掉時能跑；規格＝雛形 ②",
  accept="停掉 api 容器打首頁得 503 與維護頁；放一份假進度檔，頁面每 3 秒更新步驟、耗時、剩餘時限；沒有任何按鈕；`grep border-left` 0；e2e conf 不變"),
dict(id="P2-1", phase="P2", stage=1, repo="update-center", model="opus", effort="high",
  title="更新站 repo 骨架：TypeScript Worker（wrangler 4）、D1 六張表、客戶端三端點（check／download-url／report）、用授權中心公鑰表驗授權檔（WebCrypto）、主版號與拒絕名單判定、R2 物件 key 分主版號資料夾（v<major>/<version>/…）、真授權檔守衛測試",
  ren="new（新 repo，TS）：模組分法照 License Center（routes／service／repo）；驗章用 WebCrypto 原生 Ed25519；授權檔格式與 kid 機制 reuse jedi-license-runtime `public_keys.py` 的定義（公鑰表複製成 TS 常數，守衛測試用授權中心真簽的檔對照）；型別用 `@cloudflare/workers-types`；測試用 vitest＋`@cloudflare/vitest-pool-workers`",
  edit=[], new=["update-center/（package.json、tsconfig.json、wrangler.toml 綁既有 bucket `guidant-ai-release` 與 D1、src/index.ts、src/routes/client.ts、src/releases.ts（含 r2_key 拼法 `v<major>/<version>/<檔名>`，只在這裡拼）、src/licenses.ts、src/denylist.ts、migrations/0001_init.sql、test/、.env.example、README.md）"],
  deps=[], why="新 repo 從零；判定邏輯（合法安裝、拒絕名單、主版號、必經路徑）要一次做對且有真資料守衛",
  accept="本機 `wrangler dev`：帶 DEV 真授權檔打 check 回已發布清單＋簽章；過期授權檔照回；主版號不同回 none；拒絕名單回 403；偽造授權檔 401；download-url 回 R2 簽名網址 15 分有效；report 寫 `instance_version_reports` 不含客戶名；守衛測試用授權中心真簽出來的檔，驗不過擋部署"),
dict(id="P2-5", phase="P2", stage=1, repo="license-center", model="sonnet", effort="low",
  title="授權檔 payload 加 product_major（預設 1）；簽發畫面與 CLI 帶欄位",
  ren="extend：`modules/licensing` 的 payload 組裝加一欄；既有照不補簽，更新站把缺欄視為 1",
  edit=["src/license_center/modules/licensing/service.py", "src/license_center/modules/licensing/models.py", "alembic migration"],
  new=[], deps=[], why="一欄",
  accept="新簽的照 payload 有 `product_major: 1`；舊照驗章照過；LC 畫面顯示欄位"),

dict(id="E-1", phase="P1", stage=2, repo="main", model="sonnet", effort="low",
  title='環境卡：主 repo 改釘 jedi-remote-agent beta 版（P1-3a 發的 1.10.0b1），poetry update 只指定這支',
  ren='reuse：`poetry update jedi-remote-agent` 指定套件名（不裸跑）；不再走 path 安裝與 assume-unchanged，因為小弟已可發 beta',
  edit=['pyproject.toml（釘 `jedi-remote-agent==1.10.0b1`）', 'poetry.lock'], new=[],
  deps=["P1-3a"], why="主 repo 釘 Nexus 版，接新 port 的卡會 import 到舊版",
  accept="`pip show jedi-remote-agent` 版本 1.10.0b1、Location 在 `.venv`；`python -c 'from jedi_remote_agent.plugin.adapters import RemoteAgentAdapters;print(RemoteAgentAdapters.__dataclass_fields__.keys())'` 看得到 `dispatch_gate`；BE 8003 起得來；兩檔都 commit"),
dict(id="P1-2", phase="P1", stage=2, repo="main", model="opus", effort="medium",
  title="預檢：空間（包＋複本＋映像＋保留）、版本路徑對 release.json 的可直升起始版、資料庫連得上、驗 release.json 簽章與包雜湊、可清舊映像提示",
  ren="extend：`upgrade_check_preconditions` 既有檢查搬進 `lib/upgrade_preflight.sh` 再加項；簽章驗證用 `openssl pkeyutl` Ed25519 對 `/etc/guidant/release-keys/<kid>.pub`",
  edit=["scripts/installer/lib/upgrade_preflight.sh（P1-1 空殼）"], new=[],
  deps=["P1-1", "P1-9a"], why="預檢是「什麼都不動就擋下」的唯一機會，項目要全",
  e2e='190：空間不足印需要多少與可清多少、回非零不動；從 1.25.1 升需要必經 1.26.0 時印路徑並拒絕',
  accept='bats 用假的 `df` 與假包：空間不足印需要多少與可清多少、回非零；`min_base_version` 高於現行版時印路徑並拒絕；改一位元組的 release.json 用 `openssl pkeyutl` 驗章失敗；全過時進度檔 `preflight=ok` 含停機預估；shellcheck 綠'),
dict(id="P1-3c", phase="P1", stage=2, repo="main", model="opus", effort="medium",
  title="停接新工作、等做完：寫 draining 旗標檔、psql 輪詢進行中數、30 分提醒寫進度檔、時段結束放棄並清旗標（D19）",
  ren="extend：填 `lib/upgrade_drain.sh`；計數用 psql 直查 `background_jobs` 與 Agent 任務表，不另開端點",
  edit=["scripts/installer/lib/upgrade_drain.sh（P1-1 空殼）"], new=[],
  deps=["P1-1"], why="等待邏輯簡單但邊界多（放棄要恢復接單、不能留旗標）",
  accept='對 DEV 庫（localhost:5432）：塞一筆 running 的 `background_jobs` 時不往下走；`--deadline` 到了回特定碼、旗標檔已刪；0 筆立刻過；30 分提醒用 `--notice-after 3` 秒縮時驗；shellcheck 綠'),
dict(id="P3-2a", phase="P3", stage=2, repo="main", model="opus", effort="high",
  title="主系統更新模組骨架：product_update_jobs 表、domain／infra／app service、狀態與紀錄端點（讀進度檔＋表）、模組登記、DI",
  ren="new（新模組）照 `app/source_scan_vulndb` 的 settings service 形狀；讀進度檔用唯讀掛載；**登記型檔集中**：`config/app_modules.py` 與 `di_containers/` 這張卡一次登記完",
  edit=["config/app_modules.py", "di_containers/__init__.py（或對應容器）"],
  new=["scripts/sql/2026-xx-xx-fr167-product-update-jobs.sql", "domain/product_update/*", "infra/product_update/*", "app/product_update/service/product_update_app_service.py", "api/product_update/routes/product_update_route.py", "api/product_update/serializers/*"],
  deps=["P3-1"], why="DDD 四層＋RLS 表＋進度檔對應；後面三張卡都往這裡填",
  accept="DEV 套 migration 後表在、RLS 四指令；`GET /system/product-update/status` 回目前版本、最近檢查、進度檔內容；`GET .../history` 分頁；無能力點 403；守衛測試綠"),
dict(id="P2-2", phase="P2", stage=2, repo="update-center", model="opus", effort="medium",
  title="更新站後台：Cloudflare Access 守門的管理端點（發行版列表／撤回／指定內部先裝／拒絕名單／印章表）與簡單後台頁（雛形 ⑦）；CI 專用 token 只能登記草稿",
  ren="extend：同 repo 加 admin 路由（TS）；後台頁純 HTML＋少量 JS，不用框架",
  edit=["update-center/src/index.ts（掛 admin 路由）"], new=["update-center/src/routes/admin.ts", "update-center/public/admin.html"],
  deps=["P2-1"], why="後台不能發布，權限邊界要清楚",
  accept="沒過 Access 的請求 403；CI token 打 register-draft 成功、打 publish 403；指定 STG 指紋先裝後，用 STG 授權檔打 check 看得到草稿、用別家看不到；撤回後 check 不再回"),
dict(id="P2-3", phase="P2", stage=2, repo="update-center", model="opus", effort="high",
  title="發布 CLI guidant-release（決策者電腦）：init-keys 一正一備（passphrase）、publish <版>（取草稿、核內部先裝結果、蓋章、送回）、withdraw、rotate-key（舊鑰背書新鑰）",
  ren="new（CLI，TS，同 repo `cli/`，Node 22 內建 `node:crypto`）：Ed25519 簽章封包格式與 kid 規則照授權中心同一套，不另創；私鑰檔用 PKCS8＋AES-256 passphrase 加密、600；passphrase 從 TTY 讀，不收參數不讀 env",
  edit=[], new=["update-center/cli/guidant-release.ts（bin：guidant-release）", "update-center/cli/README.md"],
  deps=["P2-1"], why="私鑰只在這裡出現；流程要擋「內部先裝沒過就發布」",
  accept="`init-keys` 產兩組、備用印出封存說明、私鑰檔 600；`publish 1.28.0` 在內部先裝未全成功時拒絕；成功後更新站狀態變已發布且簽章可用 `.pub` 驗過；`rotate-key` 產的新清單用舊鑰與新鑰各簽一份；passphrase 不進任何檔與 log"),

dict(id="P1-3b", phase="P1", stage=3, repo="main", model="opus", effort="medium",
  title="主系統接 draining：core/maintenance_gate 讀旗標檔、worker 不撿新單、心跳不派新任務（接 P1-3a 的 dispatch_gate）",
  ren="new（30 行）＋extend：worker 撿單處加一個判斷；`core/plugins/remote_agent.py` 接 port 一行",
  edit=["core/plugins/remote_agent.py（一行）", "app/background_job/（撿單處一個判斷）"], new=["core/maintenance_gate.py"],
  deps=["E-1", "P1-3a"], why="接線＋一個守門",
  accept="放旗標檔後：worker 不撿 queued 單（既有 running 照跑完）、Agent 心跳回應不帶 pending 任務但結果照收；拿掉旗標 10 秒內恢復；test 兩條"),
dict(id="P1-4", phase="P1", stage=3, repo="main", model="opus", effort="high",
  title="停服務、複製資料庫到複本、讓升級接複本跑：lib/upgrade_db_copy、compose 資料庫目錄改吃變數、fe 掛進度檔唯讀",
  ren="extend：`docker-compose.yml` 的 pgdata 路徑改 `${GUIDANT_PGDATA_DIR:-…}`，升級時以環境變數指到複本；複製用 `cp -a`，時限＝預估三倍；證據檔不複製（D18）",
  edit=["scripts/installer/lib/upgrade_db_copy.sh（P1-1 空殼）", "docker/production/docker-compose.yml（pgdata 變數、fe 掛 upgrade/ 唯讀）"], new=[],
  deps=["P1-1"], why="資料不掉的核心：現在那份從頭到尾沒動",
  e2e='190：跑完 `pgdata-next/` 存在且舊目錄 mtime 不變；複製中 kill -9 重跑能清半套複本；時限到自動放棄回舊版',
  accept='本機 docker：對一個假 pgdata 目錄跑 db_copy → `pgdata-next/` 存在、來源 mtime 不變；複製中 kill -9 重跑能清掉半套複本；時限 1 秒對大目錄 → 放棄、來源不動；`docker compose -f docker/production/docker-compose.yml config` 用 `GUIDANT_PGDATA_DIR` 指到複本目錄、fe 的 upgrade/ 掛載是 `:ro`'),
dict(id="P1-5", phase="P1", stage=3, repo="main", model="opus", effort="medium",
  title="在複本上升級：載映像驗雜湊、init image migrate 模式對複本、migrate.sh 每支印進度行、runner 轉寫進度檔、單支 10 分無進度自動判卡",
  ren="extend：`migrate.sh` 每支 `printf 'PROGRESS\\t%d/%d\\t%s'`（它不寫進度檔，只印）；runner tail 轉寫",
  edit=["scripts/installer/lib/upgrade_apply_on_copy.sh（P1-1 空殼）", "scripts/init/migrate.sh 298"], new=[],
  deps=["P1-1"], why="卡住的判定要靠進度行，沒有進度行只能看總時限",
  e2e='190：真包真庫，進度檔逐支更新；塞 `pg_sleep(700)` 的 migration 10 分後判卡',
  accept='本機 docker postgres：`migrate.sh` 每支印 `PROGRESS\\t%d/%d\\t%s`（既有輸出不變）；runner 把它轉成進度檔 `migration 3/5 <檔名>`；塞一支 `pg_sleep(30)` 的測試 migration、`--stall-timeout 10` 秒 → 判卡回非零；載映像用假 tar 驗雜湊不符就拒'),
dict(id="P2-4", phase="P2", stage=3, repo="main", model="sonnet", effort="medium",
  title="CI 出包後登記草稿：先打更新站 register-draft 拿到 R2 目標 key，再上傳包、release.json、.sha256（CI token；bucket guidant-ai-release）",
  ren="extend：`package` job 加一步；R2 上傳用 aws cli S3 相容端點（CI 變數 `R2_ACCESS_KEY_ID`／`R2_SECRET_ACCESS_KEY`／`UPDATE_SITE_CI_TOKEN`，不入 repo）；**上傳 key 由更新站回，腳本不自己拼主版號資料夾**",
  edit=[".gitlab-ci.yml package job"], new=["scripts/build/publish_draft.sh"],
  deps=["P1-9a", "P2-2"], why="一支腳本接兩邊",
  e2e='跑一次 tag pipeline：R2 有 `v<主版號>/<版>/` 兩檔、更新站後台出現草稿；token 缺時 job 紅且訊息清楚',
  accept='`glab ci lint` 綠；`publish_draft.sh` 對本機 `wrangler dev` 與假 R2（`--r2-endpoint` 指向本機 MinIO 或 `wrangler dev` 的 r2 模擬）跑通；沒給 token 時立刻紅、訊息說缺哪個 CI 變數；repo 內 grep 不到任何 token。**不跑 pipeline**'),
dict(id="P2-6", phase="P2", stage=3, repo="main", model="sonnet", effort="medium",
  title='發布印樣進產品：DEV 公鑰檔放 scripts/installer/release-keys/、install 與 upgrade 都裝到 /etc/guidant/release-keys/、換鑰時新印樣隨包進來（PROD 印樣與缺鑰守門留給 P5-5）',
  ren='extend：填 `lib/release_keys.sh`；有什麼 `.pub` 就裝什麼；「PROD 鑰缺就紅」的守門**不在這張做**（決策者尚未 init-keys），P5-5 補',
  edit=['scripts/installer/lib/release_keys.sh（P1-1 空殼）'], new=['scripts/installer/release-keys/<dev-kid>.pub（用 P2-3 的 CLI 產一組 DEV 鑰，私鑰留本機 600、不入 repo）'],
  deps=["P1-1", "P2-3"], why="印樣是客戶端信任的根，裝錯整條驗章失效",
  e2e='190：新裝後 `/etc/guidant/release-keys/` 有檔 644；升級包帶新 kid 時裝完多一把、舊的保留',
  accept='bats：對暫存 root 跑 release_keys.sh → 目錄有檔、權限 644；包內多一把新 kid 時裝完多一把、舊的保留；私鑰 grep 不到進 repo；shellcheck 綠'),
dict(id="P3-2b", phase="P3", stage=3, repo="main", model="opus", effort="high",
  title="發現新版與下載：更新站 client、每日排程（抽 core/scheduler_update.py）、立即檢查端點、通知管理員、背景下載續傳、驗簽章與雜湊、取消下載、離線上傳（chunked 照 vulndb）",
  ren="extend：排程照 `scheduler_agent.py` 形狀新檔、`scheduler.py` 一行；上傳 reuse vulndb 的 chunk 三端點做法；通知 reuse `notification_service`；驗章 reuse jedi-license-runtime `public_keys` 的 kid 查表＋`/etc/guidant/release-keys`",
  edit=["core/scheduler.py 714（一行）", "app/product_update/service/product_update_app_service.py", "api/product_update/routes/product_update_route.py"],
  new=["core/scheduler_update.py", "app/product_update/service/update_site_client.py", "app/product_update/service/package_downloader.py", "app/product_update/service/package_verifier.py"],
  deps=["P3-2a"], why="對外連線、續傳、驗章三件事都要嚴；清單過期（D31）與降版拒收在這裡擋",
  accept="DEV 指向本機 wrangler dev：每日 03:00 與「立即檢查」都寫 `last_check`、有新版發通知；下載中斷重啟續傳；改一位元組驗章失敗並刪檔；離線上傳同一條驗證路；過期清單（issued_at+90d）拒收；版號 ≤ 現行拒收"),
dict(id="P3-3", phase="P3", stage=3, repo="fe", model="opus", effort="high",
  title="更新中心頁：目前版本、最新版本、版本說明、預檢、下載、下載中、已就緒（立即／排程／取消）、離線上傳、更新紀錄、失敗原因（雛形 ①、①b、①c、③、⑥）",
  ren="reuse：頁面結構照 `views/vulndb/VulndbManage.vue`＋`VulndbUpdateHistory.vue`；上傳元件 reuse vulndb 的 chunk 上傳；api.js 加 PRODUCT_UPDATE_*；能力點進 `capabilities.js`",
  edit=["src/config/api/api.js", "src/config/capabilities.js", "src/config/router/index.js", "i18n"],
  new=["src/views/product-update/UpdateCenter.vue", "src/views/product-update/UpdateHistory.vue", "src/views/product-update/ReadyDialog.vue"],
  deps=["P3-2a"], why="六個畫面狀態；規格＝雛形",
  accept="雛形 ①～③、⑥ 逐頁對照實打；沒有 update 能力點只看不到按鈕；`grep border-left` 0；分段用 SegmentButtons；不自訂字級"),

dict(id="P1-6", phase="P1", stage=4, repo="main", model="opus", effort="high",
  title="啟動新版、健康檢查、開放使用：新版接複本起、180 秒＋登入與主要 API 實打、換接（複本轉正、舊份留作升級前快照）、前門最後才換",
  ren="extend：health reuse 既有 180 秒 healthcheck 加實打；switch 把現有 `upgrade_switch_version` 改成「先換 api／worker、最後 fe」",
  edit=["scripts/installer/lib/upgrade_health.sh（P1-1 空殼）", "scripts/installer/lib/upgrade_switch.sh（P1-1 空殼）"], new=[],
  deps=["P1-4"], why="不可退回點前最後一道門；換接順序決定維護頁能撐到最後",
  e2e='190：故意讓登入 API 壞掉 → 不換接；換接後 `pgdata/` 是原複本、`pgdata-prev-<舊版>/` 是原本那份且 mtime 不變；維護頁撐到 fe 重建前一刻',
  accept='本機 docker：health 對一個回 500 的假 api 容器 → 非零不換接；對回 200 的假容器 → 過；switch 對假目錄：複本轉正、原份改名 `pgdata-prev-<版>` 且 mtime 不變；compose 啟動順序 api／worker 先、fe 最後（看 runner 寫的進度檔步驟時間）；shellcheck 綠'),
dict(id="P3-2c", phase="P3", stage=4, repo="main", model="opus", effort="high",
  title="更新單：立即／排程／取消／重試端點寫 upgrade-order.json；systemd path＋service 單元隨 install 裝；guidantai upgrade --from-order 讀單；排程用 systemd-run 到點；執行前重驗包；30 分前與 5 分前提醒寫給所有使用者",
  ren="extend：端點進 P3-2a 的 service；主機側填 `lib/upgrade_order.sh` 與 `lib/systemd_units.sh`；提醒 reuse 既有公告或 toast 機制（先 grep 公告模組）",
  edit=["app/product_update/service/product_update_app_service.py", "api/product_update/routes/product_update_route.py", "scripts/installer/lib/upgrade_order.sh（P1-1 空殼）", "scripts/installer/lib/systemd_units.sh（P1-1 空殼）"],
  new=["scripts/installer/systemd/guidant-upgrade-order.path", "scripts/installer/systemd/guidant-upgrade-order.service"],
  deps=["P3-2a", "P1-1"], why="D26 的本體：主系統只寫檔、主機只信單；主系統對該目錄只有寫權",
  e2e='190：按立即→5 秒內 path 單元觸發；選排程到點才跑；取消→單刪除；單上包驗不過→不跑並通知；容器對 `upgrade/orders/` 只有寫權',
  accept='DEV 8003：四個端點寫／刪 `upgrade/orders/*.json`，schema 照 design §5.5；排程單帶 `not_before`；`upgrade_order.sh --dry-run <單>` 讀單、重驗包（假包改一位元組 → 拒）、對照序列；systemd 單元檔用 `systemd-analyze verify`（在本機 docker ubuntu 容器內跑）綠；提醒走既有公告機制（寫哪支、回寫列）；shellcheck 綠'),
dict(id="P3-4", phase="P3", stage=4, repo="fe", model="sonnet", effort="medium",
  title="有新版橫幅（儀表板，只給系統管理員）、更新前 30 分與 5 分全站提醒、「這版先不提醒」（雛形 ⓪）",
  ren="reuse：全站橫幅用既有公告／toast 元件；狀態來自 P3-2a 的 status 端點",
  edit=["src/layout/（頂欄或橫幅掛點）", "i18n"], new=["src/components/product-update/NewVersionBanner.vue"],
  deps=["P3-2a"], why="兩個橫幅",
  accept="雛形 ⓪；按「這版先不提醒」後同版不再跳、新版再跳；排程前 30 分所有登入者看得到提醒"),

dict(id="P1-7", phase="P1", stage=5, repo="main", model="opus", effort="high",
  title="自動退回、開機檢查、guidantai recover：丟棄複本、改接原份、啟動保留的舊映像、健康檢查；systemd oneshot 開機讀進度檔；recover 走同一支",
  ren="extend：填 `lib/upgrade_rollback.sh`、`lib/upgrade_boot_check.sh`；recover 子命令（P1-1 已派送）呼叫同一支 rollback",
  edit=["scripts/installer/lib/upgrade_rollback.sh（P1-1 空殼）", "scripts/installer/lib/upgrade_boot_check.sh（P1-1 空殼）"],
  new=["scripts/installer/systemd/guidant-upgrade-boot-check.service"],
  deps=["P1-4", "P1-6"], why="所有失敗的終點；退回本身不能失敗",
  e2e='190：第 ⑦ 步 kill -9 整個流程 → 開機單元自動退回、舊版起來、筆數一致；`systemctl stop docker` 後 `sudo guidantai recover` 收尾',
  accept='本機 docker：進度檔停在任一未完成步驟時跑 boot_check → 呼叫 rollback：複本刪、原份接回、假的舊版容器起、進度檔 `rolled_back_at` 有值；進度檔 `succeeded` 時 boot_check 什麼都不做；`guidantai recover` 走同一支（diff 兩條路徑呼叫同一函式）；shellcheck 綠'),
dict(id="P1-10", phase="P1", stage=5, repo="main", model="sonnet", effort="medium",
  title="收尾與診斷：保留上一版映像、更新包保留到下次成功、清掉更早的；diag 併進度檔與更新單並遮罩；下載診斷包走更新單（type=diag）",
  ren="extend：填 `lib/upgrade_cleanup.sh`；`cmd_diag` 收集清單加兩個檔（guidantai 超標，只加路徑列）",
  edit=["scripts/installer/lib/upgrade_cleanup.sh（P1-1 空殼）", "scripts/installer/guidantai（cmd_diag 收集清單兩行）"], new=[],
  deps=["P1-6"], why="空間與可診斷性",
  e2e='190：升級成功兩次後只剩新版與上一版映像；更新單 type=diag 觸發後 `upgrade/diag/` 出現 zip',
  accept='本機 docker：三個版本的假映像跑 cleanup 只剩兩個；更新包保留規則（最新成功那包留、更早刪）用假檔驗；`cmd_diag` 收集清單含進度檔與更新單且 grep 守衛抓不到密碼字樣；shellcheck 綠'),
dict(id="P3-2d", phase="P3", stage=5, repo="main", model="opus", effort="medium",
  title="跨版兩段：更新站回路徑（必經版本）、下載多包、更新單帶序列、CLI 依序跑每段各自複本與健康檢查、第二段失敗停在中間版（雛形 ⑤）",
  ren="extend：check 回應的 `path[]` 由更新站算；BE 下載迴圈；`lib/upgrade_order.sh` 讀序列逐段呼叫 runner",
  edit=["app/product_update/service/product_update_app_service.py", "app/product_update/service/package_downloader.py", "scripts/installer/lib/upgrade_order.sh"], new=[],
  deps=["P3-2c", "P2-1"], why="多段串接，邊界在「第二段失敗停哪」",
  e2e='190：1.25.1 裝機、1.26.0 標必經 → 兩包都下、兩段跑完；第二段 kill → 停在 1.26.0 可用',
  accept='DEV 8003 對本機 wrangler dev：更新站標一版必經 → check 回 `path[]` 兩段、BE 兩包都下、更新單帶序列；`upgrade_order.sh --dry-run` 逐段呼叫 runner（看 log 兩次）；單段情境行為不變（既有 test 綠）'),
dict(id="P4-5", phase="P4", stage=5, repo="agent", model="opus", effort="medium",
  title="Agent 升級指令比照主系統：每步時限、進度檔、失敗自動退回（兩份 install.sh 只加一行呼叫，邏輯進 install-lib/）",
  ren="extend：既有 `_upgrade_revert`、`upgrade_guard.sh` 收進 `install-lib/upgrade_runner.sh`；deploy/install.sh 的 `cmd_upgrade` 委派新包安裝程式照舊（D30 同形）",
  edit=["scripts/install/install.sh 1387–1545（一行）", "deploy/install.sh 1153（一行）"], new=["agent/install-lib/upgrade_runner.sh", "agent/install-lib/progress.sh"],
  deps=[], why="Agent 包 1GB＋，兩份安裝程式都超標",
  e2e='lab 機（決策者指定一台）：升級中 kill → 重跑或開機自動退回',
  accept='本機 docker ubuntu 容器：對假包跑 `install-lib/upgrade_runner.sh` → 每步時限、進度檔與主系統同欄位、失敗步驟自動退回；兩份 install.sh 行數不增；shellcheck 綠'),

dict(id="P1-9b", phase="P1", stage=6, repo="main", model="opus", effort="medium",
  title="CI 跨版排練：e2e 目標先裝舊版＋灌真實舊庫，再用新包 guidantai upgrade；失敗擋 package",
  ren="extend：`deploy_e2e.sh` 既有 e2e 路徑加「先裝指定舊版」參數；舊庫用 smoke_tempdb 既有的 dump 來源",
  edit=[".gitlab-ci.yml", "scripts/build/deploy_e2e.sh"], new=[],
  deps=["P1-7"], why="1.21.0 的 fr093-4d 就是這樣抓到的；要自動化",
  e2e='pipeline 多一個 `rehearse-upgrade` job：190 裝 1.26.0 → 新包升級成功、筆數一致；放壞 migration 時 job 紅、package 不跑',
  accept='`glab ci lint` 綠；`deploy_e2e.sh --base-version 1.26.0 --dry-run` 印出會做的步驟（先裝舊版、灌 dump、再 upgrade）且參數解析有 test；job 的 `needs`／`rules` 讓它紅就擋 package（讀 yaml 驗）。**不跑 pipeline、不碰 190**'),
dict(id="P4-2", phase="P4", stage=6, repo="main", model="opus", effort="high",
  title="Agent 跟進（主系統側）：agent_update_rollouts 表、開放後自動建第一批 1 台、心跳回 target_version＋包資訊（core/plugins/_agent_update.py）、Agent 下載端點、收升級結果推進或暫停、更新中心 Agent 區端點（暫停／繼續／推選定）",
  ren="extend：照 `_agent_vulndb.py` 形狀新檔；`core/plugins/remote_agent.py` 接 `update_extra` 一行；端點進 P3-2a 模組",
  edit=["core/plugins/remote_agent.py（一行）", "app/product_update/service/product_update_app_service.py", "api/product_update/routes/product_update_route.py"],
  new=["scripts/sql/2026-xx-xx-fr167-agent-update-rollouts.sql", "core/plugins/_agent_update.py", "app/product_update/service/agent_rollout_service.py", "api/remote_agent/routes/agent_update_bundle_route.py"],
  deps=["P1-3a", "P3-2c", "P1-9a"], why="分批推進的狀態機＋下載端點；版本約束（Agent 不比主系統新）在這裡守",
  e2e='三台 Agent：主系統升完先一台成功再其餘；第一台失敗其餘 paused；推選定只對落後的台',
  accept='DEV 8003＋pytest：升級完成事件 → rollouts 一筆 batch 1 一台；該台回成功 → 其餘進 batch 2；回失敗 → paused；心跳回 `update` 只給目標批（用 test client 模擬三台心跳）；下載端點走資料面 JWT、回包＋sha；無能力點 403；守衛測試綠'),

dict(id="P4-3", phase="P4", stage=7, repo="agent", model="opus", effort="high",
  title="Agent 跟進（Agent 側）：心跳 observer 看 target_version、從主系統下載包驗雜湊、寫 agent-upgrade-order.json、主機 systemd path 觸發 install.sh upgrade、結果隨心跳回報",
  ren="extend：observer 照 `vulndb_sync.py` 形狀新檔（下載、續傳、驗 sha 都 reuse 它的做法）；`enroll.py` 掛一行；systemd 單元由 P4-5 的 install-lib 裝",
  edit=["core/enroll.py 364（一行）", "agent/install-lib/upgrade_runner.sh（裝 path 單元）"], new=["core/update_sync.py", "agent/install-lib/systemd/guidant-agent-upgrade-order.{path,service}"],
  deps=["P4-2", "P4-5"], why="Agent 不連外網、不多常駐程式，跟主系統同一套形狀",
  e2e='lab 機：心跳帶 update → 下載、驗 sha、寫單、path 單元觸發 `install.sh upgrade`，成功後心跳 `agent_version` 變新；失敗自動退回回報 failed',
  accept='Agent pytest：假心跳帶 update → 下載到暫存、sha 對、寫 `agent-upgrade-order.json`；sha 不對 → 刪檔回報；主系統 paused（無 update）時不動；observer 掛點一行；systemd 單元檔 `systemd-analyze verify` 綠（docker ubuntu）'),
dict(id="P4-4", phase="P4", stage=7, repo="fe", model="sonnet", effort="medium",
  title="更新中心 Agent 跟進區：批次表、暫停／繼續、推選定、只升 Agent 說明（雛形 ④）",
  ren="reuse：表格照主站 DataTable 慣例；接 P4-2 端點",
  edit=["src/views/product-update/UpdateCenter.vue"], new=["src/views/product-update/AgentRolloutPanel.vue"],
  deps=["P4-2", "P3-3"], why="一個面板",
  accept="雛形 ④；第一台失敗後其餘顯示已暫停；推選定只能勾落後的"),

dict(id="P5-1", phase="P5", stage=8, repo="main", model="opus", effort="medium",
  title="手冊與安裝文件：升級一章改寫（更新中心、離線上傳、排程、失敗怎麼看、recover、diag）、防火牆放行清單、資安例外登記（對外更新站）、回報出貨基線待重產",
  ren="extend：手冊既有章節；例外登記進 `docs/security-report/requirements/`",
  edit=["docs/user-manual/", "docs/security-report/requirements/", "scripts/installer/README.md"], new=[],
  deps=["P4-3", "P4-4"], why="客戶 IT 要看得懂「什麼情況要敲 recover」",
  accept="章節齊；文件衛生 grep 0；例外登記可查；回寫明講出貨基線待重產"),
dict(id="P5-2", phase="P5", stage=8, repo="main", model="sonnet", effort="medium",
  title="SPEC：更新中心頁新頁（writing-feature-specs）",
  ren="new（spec 頁）照既有頁形狀",
  edit=[], new=["docs/spec-site/current/system-admin/product-update.md"],
  deps=["P4-4"], why="量產文件",
  accept="頁面齊、變更紀錄一行、mkdocs build 綠"),
dict(id="P5-3", phase="P5", stage=8, repo="test", model="sonnet", effort="medium",
  title="e2e：更新中心頁（有新版→下載→就緒→排程→取消；離線上傳驗不過；無權限只看）對 stub 更新站",
  ren="reuse：site-regression 既有 page object；更新站用 wrangler dev stub",
  edit=[], new=["site-regression features/steps（product-update）"],
  deps=["P3-3", "P3-4"], why="照既有套件補 case",
  accept='headless 綠（BE 8003＋FE dev＋wrangler dev stub 由小弟自己起）；不實際執行升級；收工前把三個背景服務停掉'),
dict(id="P5-5", phase="P5", stage=8, repo="main", model="sonnet", effort="low",
  title='結案卡：jedi-remote-agent 發正式版（決策者確認套件清單後）、主 repo 釘正式版、PROD 印樣進 scripts/installer/release-keys/ 並補「PROD 鑰缺就紅」守門、poetry update 指定套件',
  ren='reuse：照 jedi-package-dev skill 發版流程；守門照 build_bundle 既有 `--skip-prod-key-check` 精神',
  edit=['pyproject.toml', 'poetry.lock', 'scripts/build/build_bundle.sh（PROD 印樣缺就紅）'], new=['scripts/installer/release-keys/<prod-kid>.pub（決策者 init-keys 後交付）'],
  deps=["P4-3"], why="環境卡的對稱收尾",
  accept='`pyproject.toml` 釘正式版且 `pip show` Location 在 `.venv`；`git status` 乾淨；沒 PROD 印樣 build_bundle 紅、有就綠；發版紀錄寫 commit'),
# ── Stage 9／10：開發期收完後的修正與收尾（2026-10-30 首腦第 3 棒開；決策者 11 題「都照建議」）──
dict(id="P6-1", phase="P6", stage=9, repo="fe", model="sonnet", effort="medium",
  title='離線上傳補送清單：使用者一併選 guidant-ai-<版>.release.json，原文帶進 startUpload 的 manifest；useChunkedUpload 加選填參數 extend 不另寫',
  ren='extend：`useChunkedUpload` 加一個選填 `extra`（startUpload 多帶的欄位）；`ProductUpdateService.startUpload` 多收 `manifest`；`UpdateCenter.vue` 離線區多一個 `.release.json` 檔案框。vulndb 呼叫端不傳 extra 行為不變。不開第二支 composable',
  edit=['src/composables/useChunkedUpload.js', 'src/service/ProductUpdateService.js', 'src/composables/useProductUpdate.js', 'src/views/product-update/UpdateCenter.vue', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json'], new=[],
  deps=["P3-3", "P3-2b"], why="P5-3 e2e 抓到的真缺口：FE 只送 total_size／total_chunks，BE `ProductUpdateUploadStartSchema.manifest` 必填，真實使用者按上傳一律 400。BE 不放寬（§5.8 先驗清單才收包），改 FE 是唯一方向",
  accept='vitest 綠；headless Playwright：選包不選清單→按鈕停用並提示；兩個都選→送出的 POST body 有 `manifest` 原文；對 DEV 8003 真打一次 400 消失（清單用 update-center `cli/envelope.ts` 真簽的信封）；vulndb 上傳頁回歸不壞',
  e2e='190：更新中心離線上傳，選包＋清單，走到已就緒'),
dict(id="P6-2", phase="P6", stage=10, repo="test", model="sonnet", effort="low",
  title='e2e 拆掉離線上傳 route 補丁：steps 第 140～146 行刪、兩條改走真實送法（多選一個 release.json）、拿掉 @known-bug',
  ren='extend：既有 `product-update.steps.js` 與 feature；不新開檔',
  edit=['site-regression/steps/system-admin/product-update.steps.js', 'site-regression/features/modules/system-admin/product-update.feature'], new=[],
  deps=["P6-1"], why="P6-1 修好後補丁就是假綠來源；移除條件 feature 檔第 37～41 行已寫明",
  accept='`npx cucumber-js --dry-run --tags @product-update` 無未定義 step；離線兩條不帶 @known-bug、預設跑；對本機 BE 8003＋FE dev＋wrangler dev stub 跑 @product-update 全綠（起的服務收工停掉）'),
dict(id="P6-3", phase="P6", stage=9, repo="update-center", model="sonnet", effort="medium",
  title='版本說明三段進更新站後台：product_releases 加 release_notes／impact／action 三欄，後台頁發布前人填，check 回應帶出',
  ren='extend：D1 migration `0002`；`routes/admin.ts` 既有 PATCH 發行版端點多收三欄；`routes/client.ts` check 回應多帶 `notes`（陣列）、`impact`、`action`；`public/admin.html` 既有發行版表加三個輸入框。不新開端點',
  edit=['src/routes/admin.ts', 'src/routes/client.ts', 'src/releases.ts', 'public/admin.html'], new=['migrations/0002_release_notes.sql'],
  deps=["P2-2"], why="決策者裁：三段文字放更新站後台、發布前人填（release.json 一簽就改不了，後台可改到發布前一刻）。FE `VersionSummary.vue` 已收 `release.notes` 字串或陣列、i18n 已有「影響範圍」「建議動作」鍵，只差資料源",
  accept='vitest-pool-workers 綠：PATCH 填三欄→GET 看得到；check 回應含三欄；不填時三欄為空陣列／空字串不是 undefined。`wrangler dev` 本機對 D1 local 套 0002；不 deploy',
  e2e='190：後台填說明→更新中心「版本說明」區顯示三段'),
dict(id="P6-4", phase="P6", stage=10, repo="main", model="sonnet", effort="low",
  title='更新中心 status 轉出版本說明三段：update_site_client 收 notes／impact／action 寫進 available，FE 不改',
  ren='extend：`update_site_client.check()` 回傳多三鍵；`product_update_flow` 組 `available` 時帶出。FE `VersionSummary.vue` 已會讀 `release.notes`，`impact`／`action` 若 FE 鍵名不同以 FE 現有為準對齊',
  edit=['app/product_update/service/update_site_client.py', 'app/product_update/service/product_update_flow.py'], new=[],
  deps=["P6-3"], why="P6-3 的 BE 側接線",
  accept='pytest 該模組綠；對 wrangler dev stub 回有三欄的 check，`GET /system/product-update/status` 的 `available` 帶出三欄；stub 不回三欄時不炸、為空'),
dict(id="P6-5", phase="P6", stage=10, repo="update-center", model="opus", effort="medium",
  title='更新站不再收授權檔原文：三端點身分改收 license_id＋kid＋簽章片段，Worker 用公鑰表驗片段、不再經手 issued_to',
  ren='extend：`routes/client.ts` 身分關 `caller()` 改收 `{license_id, kid, fingerprint, current_version, proof}`；`licenses.ts` 加 `verifyProof()`（對 license_id＋fingerprint＋時間窗 canonical 驗 Ed25519）。保留舊 `license` 欄位一版相容（有 `license` 就走舊路，否則走新路），下一主版拔掉。拒絕名單仍只認 license_id',
  edit=['src/routes/client.ts', 'src/licenses.ts', 'test/'], new=[],
  deps=["P2-1"], why="決策者裁：授權檔原文含 `issued_to` 客戶名稱，更新站只需編號、指紋、版號、結果，原文經 Cloudflare 是多餘暴露（D9 精神）。這張是 Worker 側（與 P6-3 都改 client.ts 所以晚一批），BE 側 P6-6 再下一批",
  accept='vitest：新路徑合法 proof 通過、改一位元組拒絕、過期時間窗拒絕、舊 `license` 欄位仍通過；突變把 verifyProof 拿掉對應測試紅；`wrangler dev` 本機實打'),
dict(id="P6-6", phase="P6", stage=11, repo="main", model="sonnet", effort="medium",
  title='主系統送更新站改送 license_id＋簽章片段：update_site_client._identity() 改組 proof，不再送授權檔原文',
  ren='extend：`update_site_client._identity()` 與 `_host_identity()`；proof 怎麼產照 P6-5 定的 canonical 與時間窗，用 jedi-license-runtime 既有的授權檔解析取 license_id／kid，簽章片段取授權檔內既有簽章（不新產私鑰、主系統沒有私鑰）',
  edit=['app/product_update/service/update_site_client.py', 'app/product_update/service/product_update_flow.py', 'docs/user-manual/（手冊「送出什麼」那段改回只送編號）'], new=[],
  deps=["P6-5"], why="P6-5 的主系統側；手冊 P5-1 現在照實寫「送原文」，改完要改回",
  accept='pytest 綠；對 P6-5 的 wrangler dev 實打 check／download-url／report 三端點都過；grep 送出的 body 沒有 `issued_to`；手冊段落已改'),
dict(id="P6-7", phase="P6", stage=9, repo="main", model="sonnet", effort="low",
  title='PROD 印樣與預設網域落地：放 PROD-<kid>.pub、installer 讀 DEFAULT_UPDATE_SITE_URL 寫進 guidant.env、188 跑一次 build_bundle dry-run',
  ren='extend：`install.sh` 寫 `guidant.env` 那段（P1-1 owner 卡已結，本卡可碰）讀包內 `DEFAULT_UPDATE_SITE_URL` 檔，有值且 `guidant.env` 沒設 `UPDATE_SITE_URL` 才寫；升級路徑同理在 `lib/upgrade_switch.sh` 或 `install.sh --upgrade-phase config` 補。公鑰檔照 P2-6 的 `release_keys.sh` 既有「有什麼 .pub 就裝什麼」，不改',
  edit=['scripts/installer/install.sh（只加一行呼叫）', 'scripts/installer/lib/release_keys.sh（若要認 PROD- 前綴）'], new=['scripts/installer/lib/update_site_default.sh', 'scripts/installer/release-keys/PROD-<kid>.pub（決策者產鑰後交付；沒有就先不放、回寫說明）'],
  deps=["P5-5"], why="結案卡 CM-3276 拆出的三件：PROD 公鑰（等決策者 init-keys）、installer 讀預設網域（小弟當時不碰 owner 檔）、188 整支 dry-run（小弟 mac bash 3.2 跑不了）。印章放哪台、正式網域決策者說「先測試、流程面後續再了解」，本卡值留空、機制做好",
  accept='bats：包內檔有值且 env 沒設→寫入；env 已設→不覆寫；檔空→不寫。shellcheck 綠。**PROD 公鑰**：決策者交付了就放並 `build_bundle.sh` 綠，沒交付就回寫「待決策者 init-keys」、本卡其餘照做。**188 dry-run**：唯讀 ssh 到 188 `/opt/guidant-ai-be` 看 bash 版本後，在 ubuntu:24.04 容器跑 `build_bundle.sh --dry-run --skip-prod-key-check`（不出真包、不碰 188 檔案）',
  e2e='190：新裝後 `guidant.env` 有 `UPDATE_SITE_URL`；清空後更新中心顯示未設定'),

# ── Stage 14～16：雛形 v2 定案後的後端／出包／收尾（2026-10-30 首腦第 3 棒開；決策者「開卡準備修正」）──
dict(id="P7-1", phase="P7", stage=14, repo="main", model="opus", effort="medium",
  title='Agent 跟進改成可選：apply 收 with_agents（勾才在主系統升完後自動跟進）、push 加排程時間、「稍後提醒」7 天存後端',
  ren='extend：`_start_following` 保留，改成只在該版更新單 with_agents=true 時才建列、且只對 agent_uids（空＝全部）建；apply schema 加 `with_agents`（預設 true）與 `agent_uids` 存進更新單；`push()` 多收選填 `not_before`，到點前列維持 pending；稍後提醒存 `system_config`（ROOT 租戶，key 帶目標版號＋到期時間），不新建表',
  edit=['app/product_update/service/agent_rollout_service.py', 'app/product_update/service/product_update_app_service.py', 'api/product_update/routes/product_update_route.py', 'api/product_update/serializers/product_update.py'], new=[],
  deps=["P4-2"], why="決策者 2026-10-30 裁：Agent 讓使用者選跟主系統一起更新（主系統升完接著推）或分開（之後自己按、可排程、可稍後提醒 7 天）；D16 由一律自動改為可選",
  accept='pytest：with_agents=true 的更新單完成後 tick 建列、false 時不建列、舊更新單沒有這欄視為 true；agent_uids 只勾 1 台時只建 1 列；push 帶 not_before 時列是 pending 且到點前 _advance 不下發、到點後下發；稍後提醒寫入後 7 天內概覽回 snoozed=true、過期回 false；突變把 not_before 判斷拿掉對應測試紅。DEV 8003 實打 push／remind 端點',
  e2e='190：①勾「接著更新 Agent」升主系統，升完先 1 台再其餘；②不勾升主系統，Agent 不動，再勾 3 台排程今晚 22:00，到點先 1 台再其餘'),
dict(id="P7-2", phase="P7", stage=14, repo="main", model="opus", effort="medium",
  title='「更新到 X」沒包也能送：apply 自動先下載、下載就緒後才寫更新單；排程保證到點前下完',
  ren='extend：`upgrade_order.apply()` 收 available 狀態時建一筆 downloading 單並記「下載完自動 apply」（mode／not_before 存在 job），下載 handler 就緒時接著 `_enqueue`；排程離到點不足預估下載時間時回 412 說明',
  edit=['app/product_update/service/upgrade_order.py', 'app/product_update/service/product_update_flow.py'], new=[],
  deps=["P3-2c"], why="雛形 v2 主按鈕「更新到 X」：一鍵完成下載、驗證、更新；FE 目前用頁面記憶體暫接，重整就丟",
  accept='pytest：available 時 apply → 建下載單，下載就緒後自動產出更新單（mode／not_before 照原值）；下載失敗不寫更新單、紀錄 failed；ready 時 apply 行為不變；排程時間早於預估下載完成回 412；突變拿掉「就緒才寫單」那道判斷要紅',
  e2e='190：未下載時按更新到 X 選今晚 22:00，白天下完，到點自動更新'),
dict(id="P7-3", phase="P7", stage=14, repo="main", model="sonnet", effort="medium",
  title='出包：release.json 加 min_agent_version；另產單一離線檔 guidant-ai-<版>-offline.tar（包＋簽好的清單）',
  ren='extend：`scripts/build/release_json.sh` 加欄位（讀 `scripts/build/min_agent_version` 一行檔，沒有就等於本版）；`build_bundle.sh` 出包後多打一個 tar 把 `.tar.gz` 與 `.release.json` 包在一起（清單是決策者 publish 後才有，所以離線檔在 publish 後由 CLI 產或 CI 第二階段產，回寫說明選哪個）',
  edit=['scripts/build/release_json.sh', 'scripts/build/build_bundle.sh'], new=['scripts/build/min_agent_version'],
  deps=["P1-9a"], why="雛形 v2「必須更新」需要知道新版主系統最低接受哪版 Agent；離線客戶只想選一個檔",
  accept='bats：release.json 有 min_agent_version 且預設等於本版；離線 tar 內恰好兩個檔、名稱固定；shellcheck 綠；在 ubuntu 容器跑'),
dict(id="P7-4", phase="P7", stage=14, repo="agent", model="sonnet", effort="low",
  title='Agent 包附自己的版本說明：build 時讀 RELEASE_NOTES.md 寫進 agent manifest 的 notes 欄',
  ren='extend：`scripts/build/gen_agent_manifest.py` 多收 `--notes-file`，`build_all.sh` 傳 repo 根目錄 `RELEASE_NOTES.md` 的本版段落（3 到 5 條白話）',
  edit=['scripts/build/gen_agent_manifest.py', 'scripts/build/build_all.sh'], new=['RELEASE_NOTES.md'],
  deps=["P4-3"], why="雛形 v2 Agent 區要顯示「Agent X 版本說明」，目前 Agent 包沒有這資料",
  accept='pytest／bats：manifest 有 notes 陣列、最多 5 條；沒有該版段落時為空陣列不報錯'),
dict(id="P7-5", phase="P7", stage=15, repo="main", model="opus", effort="medium",
  title='主系統接新資料：上傳收單一離線檔、status 帶最近一次成功升級時間、Agent 概覽帶最低相容版／版本說明／提醒狀態、紀錄合併主系統與 Agent',
  ren='extend：`complete_upload` 偵測到 tar 先在 `.uploads/` 拆開再走原本「先驗清單再核包」；仍收舊的兩檔送法一版相容。status 加 `last_upgraded_at`（最近一筆 succeeded 的 ended_at）。`overview()` 加 `min_agent_version`、`required`（任一台低於最低相容）、`notes`、`snoozed_until`。history 加 `target` 欄（host／agent），Agent 列由 rollouts 依批次彙整',
  edit=['app/product_update/service/product_update_flow.py', 'app/product_update/service/agent_rollout_service.py', 'app/product_update/service/product_update_app_service.py', 'api/product_update/serializers/product_update.py'], new=[],
  deps=["P7-1", "P7-2", "P7-3", "P7-4"], why="把 P7-1～4 的資料接到 FE 要的契約；與 P7-1／P7-2 改同兩支檔所以晚一批",
  accept='pytest：單一離線檔與兩檔送法都能就緒、tar 內多檔或少檔拒收；改一位元組驗不過即刪；last_upgraded_at 取最近成功那筆；required 在任一台低於 min_agent_version 時為 true；history 同一張表含 host 與 agent 列；突變拿掉「先驗清單」要紅'),
dict(id="P7-6", phase="P7", stage=16, repo="fe", model="sonnet", effort="medium",
  title='更新中心接上後端新契約：必須更新紅色路徑、Agent 排程與稍後提醒改後端、離線改單一檔、紀錄合併、「更新於」、拿掉頁面記憶體暫接',
  ren='extend：既有 `UpdateCenter.vue`／`AgentRolloutPanel.vue`／`UpdateConfirmDialog.vue`／`OfflineUploadDialog.vue`／`UpdateHistory.vue`；`agentRolloutState.js` 的 localStorage 稍後提醒刪掉改讀 `snoozed_until`',
  edit=['src/views/product-update/UpdateCenter.vue', 'src/views/product-update/AgentRolloutPanel.vue', 'src/views/product-update/UpdateConfirmDialog.vue', 'src/views/product-update/OfflineUploadDialog.vue', 'src/views/product-update/UpdateHistory.vue', 'src/views/product-update/agentRolloutState.js', 'src/service/ProductUpdateService.js', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json'], new=[],
  deps=["P7-5"], why="雛形 v2 剩下要後端資料才做得出來的部分；FE 版面已於首腦第 3 棒套上（commit b83349c9 起）",
  accept='vitest：required=true 時 Agent 標紅「必須更新」、主系統確認視窗出現紅字並要勾「我了解」才能下一步；Agent 確認視窗有排程；離線只一個檔框；紀錄表有「對象」欄；pendingApply 已移除改一次呼叫 apply。headless 截圖對照雛形 ①～④',
  e2e='190：同 design §7-7／8／9／14'),
dict(id="P7-7", phase="P7", stage=17, repo="test", model="sonnet", effort="low",
  title='e2e 更新中心頁物件改綁 v2 版面（pu-download／pu-offline 區塊已拆，按鈕與對話框 data-test 換新）',
  ren='extend：`ProductUpdatePage.js` 與 `product-update.steps.js`；不新開檔',
  edit=['site-regression/pages/system-admin/ProductUpdatePage.js', 'site-regression/steps/system-admin/product-update.steps.js', 'site-regression/features/modules/system-admin/product-update.feature'], new=[],
  deps=["P7-6"], why="v2 版面拿掉了 pu-download／pu-offline 區塊與 ReadyDialog，舊 selector 全斷",
  accept='`npx cucumber-js --dry-run --tags @product-update` 無未定義 step；對本機 BE 8003＋FE dev＋update-site-stub 跑 @product-update 全綠（起的服務收工停掉）'),
dict(id="P7-8", phase="P7", stage=16, repo="main", model="sonnet", effort="low",
  title='文件同步：design.md D16 改寫成「Agent 可選跟主系統一起或分開更新」、SPEC 更新中心頁照 v2 改寫、手冊離線更新改「向原廠索取單一離線檔」',
  ren='extend：design.md §3 D16 與 §5 對應段；`docs/spec-site/current/system-admin/product-update.md`；`docs/user-manual/onprem/05-upgrade-rollback.md`',
  edit=['docs/features/FR-167-2610-online-update-channel/design.md', 'docs/spec-site/current/system-admin/product-update.md', 'docs/user-manual/onprem/05-upgrade-rollback.md'], new=[],
  deps=["P7-5"], why="D16 由一律自動跟進改為可選；SPEC 與手冊還寫一律自動跟進與兩檔上傳",
  accept='design.md D16 寫明「確認視窗勾選才跟進，預設勾」；SPEC 檔頭變更紀錄加一行；mkdocs build --strict 綠；只 build spec-site 自己、只 add 自己改的檔'),
# ── P8 190 測試期修正（2026-10-30 第 5 棒：四次線上更新實測抓到的問題；待辦編號對應 STATE §1）──
# 已修 commit 的（16 bugsink 變數／18 退回起 bugsink／20 重試跳第 0 步／21 憑證換回不搬目錄／23 診斷包權限）不再開卡，隨結案卡進包。
dict(id="P8-1", phase="P8", stage=18, repo="main", model="opus", effort="high",
  title='主機升級程式：第 0 步時限依包大小、第 0～2 步失敗記 skipped、步驟白話名與預檢 detail 分列、換接後清 last_check 與同步更新單',
  ren='extend：`lib/upgrade_runner.sh`（時限、upg_fail 的 0～2 步狀態、error 用白話步驟名）、`lib/progress.sh`（新增 `progress_step_label` 一份對照表供 runner／order 共用）、`lib/upgrade_preflight.sh`（detail 改 `\\n` 分列）、`lib/upgrade_switch.sh`（換接完成寫 `upgrade/last-switch.json` 讓後端立刻重查）；不新開檔',
  edit=['scripts/installer/lib/upgrade_runner.sh', 'scripts/installer/lib/progress.sh', 'scripts/installer/lib/upgrade_preflight.sh', 'scripts/installer/lib/upgrade_switch.sh', 'test/shell/upgrade_skeleton.bats', 'test/shell/upgrade_preflight.bats'], new=[],
  deps=[], why="待辦 1／11／14／17／24 都在主機升級程式同幾支檔，拆開必撞；一張卡一次改完再跑 bats",
  accept='bats `upgrade_skeleton`／`upgrade_preflight`／`upgrade_order` 在 ubuntu:24.04 容器全綠；新 case：①假 5GB 包（`truncate`）第 0 步時限 ≥300；②第 1 步失敗進度檔 status=skipped、result=unchanged、error 含「預檢」不含 `preflight`；③預檢 detail 每項一行；④`progress_step_label 4` 印「在複本上升級」'),
dict(id="P8-2", phase="P8", stage=18, repo="main", model="opus", effort="medium",
  title='後端更新中心：status 露出「已送單等主機」中間態、換接完成立即同步紀錄與重查新版、診斷包讀不到回 4xx、預檢可在預先下載後先跑',
  ren='extend：`app/product_update/service/upgrade_order.py`（`sync_from_progress` 認 `last-switch.json`；診斷包讀檔 PermissionError 轉 412）、`product_update_flow.py`（status 多 `submitted` 狀態、換版後清 `last_check` 並 `check_now`）、`product_update_app_service.py`（預先下載完成可觸發一次只讀預檢）；不新開 service',
  edit=['app/product_update/service/upgrade_order.py', 'app/product_update/service/product_update_flow.py', 'app/product_update/service/product_update_app_service.py', 'core/scheduler_update.py', 'common/code/product_update_error_code.py'], new=[],
  deps=[], why="待辦 7／13／19／23（後端半）／24 全在 product_update 這組 service；與 P8-1 不同 repo 層、不同檔，可同批",
  accept='既有 `test/test_product_update_*.py` 全綠；新 case：①job 寫單後、主機未寫 running 前 status.latest_job.status==`submitted`；②進度檔 switch ok 後一次 tick 內 job→succeeded 且 last_check 被清；③診斷包檔案 0600 時端點回 412 帶 PRODUCT_UPDATE_DIAG_UNREADABLE 不是 500；④預先下載 ready 後 status.progress 有 preflight 步'),
dict(id="P8-3", phase="P8", stage=18, repo="fe", model="opus", effort="high",
  title='更新中心頁：按下到維護頁的完整狀態鏈（送出／驗包／已交主機／主機驗包）、升級中輪詢失敗自動跳維護頁、失敗終態正確顯示並停輪詢、失敗步驟名白話、預檢分列、升級完成強制重載',
  ren='extend：`productUpdateState.js`（deriveView 多 `submitted`、`rollback_failed`；步驟名對照）、`UpdateCenter.vue`（輪詢 503／網路錯 → `location.reload()`；`?upgraded=1` 強制重載並清舊 payload）、`FailurePanel.vue`、`PreflightCard.vue`（detail 以換行拆清單）、`useProductUpdate.js`；i18n `product-update.json` 加 `step_self_replace`、中間態文案、改「立即」5 分鐘文案',
  edit=['src/views/product-update/productUpdateState.js', 'src/views/product-update/UpdateCenter.vue', 'src/views/product-update/FailurePanel.vue', 'src/views/product-update/PreflightCard.vue', 'src/composables/useProductUpdate.js', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json', 'src/views/product-update/__tests__/productUpdateState.spec.js'], new=[],
  deps=[], why="待辦 2／10／13／14（FE 半）／15／17（FE 半）／19／22（更新中心半）／24（FE 半）全在更新中心頁與其狀態檔；維護頁另一張（不同檔）",
  accept='`npm run test:unit -- productUpdateState` 全綠；新 case：deriveView(submitted)→`submitted`、(rollback_failed)→停輪詢；本機 FE dev 對假 status 回 `failed/unchanged`、`submitted`、`rollback_failed` 三種 payload 截圖三張；`grep -rn border-left` 0；「立即」文案依卡上決策段'),
dict(id="P8-4", phase="P8", stage=18, repo="fe", model="sonnet", effort="medium",
  title='維護頁：退回失敗終態（紅色、recover 指引、停「勿關機」）、第 0 步與 rollback 白話名、預檢與失敗 detail 分列、完成後帶 ?upgraded=1 跳回',
  ren='extend：`public/maintenance/maintenance.js`（`renderProgress` state 多 `rollback_failed`；`KNOWN_STEPS` 加 `self_replace`；i18n 表加 `step_self_replace`／`step_rollback`；detail 以 `\\n` 拆 `<li>`；`probeBackend` 回來 reload 時加 `?upgraded=1`）、`maintenance.css`',
  edit=['public/maintenance/maintenance.js', 'public/maintenance/maintenance.css'], new=[],
  deps=[], why="待辦 17（維護頁半）／22／24（跳回半）只動維護頁兩支靜態檔，與 P8-3 不撞",
  accept='本機以假進度檔三份（running／rolled_back／rollback_failed）開 `maintenance.html`：rollback_failed 標題紅、顯示 `sudo guidantai recover`、無「勿關機」、進度條隱藏；步驟列印「換成新版維運指令」「自動退回」不印代號；預檢 detail 多行；`grep border-left` 0；完成跳回網址含 `?upgraded=1`'),
dict(id="P8-5", phase="P8", stage=18, repo="agent", model="opus", effort="medium",
  title='Agent：主系統可主動叫它立刻心跳（POST /update/notify）、升級解包階段先寫進度',
  ren='extend：`core/data_plane_auth.py` `_PROTECTED_PREFIXES` 加 `/update`；`core/heartbeat_wake.request()` 既有原語直接用；`deploy/install.sh` `cmd_upgrade_host` 收單後先 `progress_init`＋第 0 步「解開更新包」再 `tar -xzf`。new：`guidant_agent/sync/update_notify.py`（Flask-RESTful Resource 一支，`core/` 已凍結不可新增）＋在 `guidant_agent/platform/registry.py` 的 `_register_legacy` 掛一行（它是 blueprint 註冊的 owner）',
  edit=['core/data_plane_auth.py', 'deploy/install.sh', 'scripts/install/install.sh', 'guidant_agent/platform/registry.py', 'test/test_module_boundaries.py'], new=['guidant_agent/sync/__init__.py', 'guidant_agent/sync/update_notify.py', 'test/test_update_notify.py'],
  deps=[], why="待辦 26（Agent 半）／27 都在 Agent repo；主系統那半在 P8-6（下一批，要打這個端點）",
  accept='pytest `test/test_update_notify.py`：mode=full 無 JWT 401、JWT 正確 200 且 `heartbeat_wake` 被 set、mode=none 直接 200；shell：假 1.7GB 包 upgrade-host 開跑 2 秒內進度檔已有第 0 步 running；shellcheck 綠；bootstrap 守衛測試綠'),
dict(id="P8-6", phase="P8", stage=19, repo="main", model="opus", effort="medium",
  title='主系統推送 Agent 後主動通知它立刻心跳（走 FR-039 mTLS 資料面，連不到靜默回等心跳）',
  ren='extend：`app/product_update/service/agent_rollout_service.py` `_rebatch` 與 `_advance` 下發後呼叫 notify；new：`infra/remote_agent/agent_update_notify_client.py`（照 `agent_credential_client.py` 的 `_send` 複用 `build_cloud_mtls_context`＋`jwt_util.mint`，不第三份 auth）；`di_containers/product_update/product_update_containers.py` 注入',
  edit=['app/product_update/service/agent_rollout_service.py', 'di_containers/product_update/product_update_containers.py'], new=['infra/remote_agent/agent_update_notify_client.py', 'test/test_agent_update_notify_client.py'],
  deps=["P8-5"], why="待辦 26 主系統半；要打 P8-5 做出來的端點，且改 rollout service（P8-2 不碰那支，但 di_containers 是登記型檔，與 P8-2 分批保險）",
  accept='pytest：notify 對假 server 送 Bearer＋mTLS；連線失敗只 warning 不 raise、rollout 狀態不變；mode=none 走裸 http；`push()` 與自動跟進 `_advance` 各呼叫一次（mock 計數）'),
dict(id="P8-7", phase="P8", stage=19, repo="main", model="sonnet", effort="medium",
  title='全新安裝資料缺口：role-manage 幽靈選單、角色詳細頁 500（能力點 license_module 空）、1.26.0 排練基底',
  ren='extend：主線補一支 `phase=active` migration 在套件 001 之後刪 `role-manage`（同 fr132 先例）、一支回填能力點 `license_module`（查 `_capabilities.sql` 真相）；CI `deploy_rehearse` 的 `--base-version` 預設改 1.27.0（決策者先裁：見卡上「先回寫問決策者」）',
  edit=['scripts/sql/manifest.tsv', 'scripts/build/deploy_rehearse.sh', '.gitlab-ci.yml'], new=['scripts/sql/2026-10-31-fr167-drop-role-manage-after-iam-001.sql', 'scripts/sql/2026-10-31-fr167-capability-license-module-backfill.sql'],
  deps=[], why="待辦 3／4／5：全新安裝才會出現、與更新流程無關，獨立一張；動 manifest.tsv（登記型）故與 P8-1／P8-2 不同批",
  accept='本機用出貨 init image 空庫 init＋migrate：`ui_routes` 無 `role-manage`、`capabilities where license_module is null` 0 筆、`GET /ui-routes` 200；STG 唯讀 dump 升級路徑兩支都冪等；回寫明講「出貨基線待重產」'),
dict(id="P8-8", phase="P8", stage=20, repo="main", model="sonnet", effort="medium",
  title='文件同步：SPEC 更新中心頁（檢查改每 6 小時＋啟動、中間態、失敗終態、Agent 主動通知）、手冊升級章、design §5 對應段、13 個錯誤碼 FE 翻譯',
  ren='extend：`docs/spec-site/current/system-admin/product-update.md`（:242 cron 改掉）、`docs/user-manual/onprem/05-upgrade-rollback.md`、design.md §5；FE `error-code.json` 補 13 個 `PRODUCT_UPDATE_*`（這張卡改 FE 檔但只一支 i18n，與 P8-3 不同批）',
  edit=['docs/spec-site/current/system-admin/product-update.md', 'docs/user-manual/onprem/05-upgrade-rollback.md', 'docs/features/FR-167-2610-online-update-channel/design.md'], new=[],
  deps=["P8-1", "P8-2", "P8-3", "P8-4", "P8-5", "P8-6"], why="待辦 8／9；定案後量產文件，等程式卡全收完才寫才不會寫到被改掉的行為",
  accept='`mkdocs build --strict` 綠；SPEC 檔頭變更紀錄加一行；手冊「升級失敗怎麼辦」段含 recover；`grep -n "03:00" product-update.md` 0'),
dict(id="P8-9", phase="P8", stage=21, repo="main", model="opus", effort="high",
  title='結案：本機驗新裝與升級 → 188 出 1.27.2 包 → 測試章簽清單 → 更新站發布 → 190 升級驗全流程 → 刪 190 systemd 繞道',
  ren='reuse：全部既有流程（`build_all.sh`／`build_bundle.sh --skip-prod-key-check`／`sign-release.ts`／`wrangler`）；本卡不改程式，只做出包與驗證；**測試期段由首腦操作 190、決策者看畫面，小弟只做到「188 出包＋本機驗」**',
  edit=['pyproject.toml'], new=[],
  deps=["P8-1", "P8-2", "P8-3", "P8-4", "P8-5", "P8-6", "P8-7", "P8-8"], why="待辦 12：全部修完一次打包；出包前新裝（出貨 init image 空庫）與升級（STG 唯讀 dump＋migrate.sh）本機各驗一次；出包後 190 從 1.27.1 升 1.27.2 走真實線上更新，過了才刪 `UPG_LIMIT_SELF_REPLACE=600` 繞道",
  accept='188 `.build/bundle/guidant-ai-1.27.2.tar.gz` 存在且 `bundle-manifest.sha256` 齊；本機新裝 148 支／0 錯、升級 164 支／0 錯；`audit_deps.sh` 0 項；版號 BE／FE 皆 1.27.2；回寫列出包內含的修正 commit 清單'),
# ── P9 1.27.2 測試期第二輪：A 升級中畫面當次可驗、B 第 0 步秒級、D 快速出包，＋幽靈選單／Agent 進度／亮色 ──
dict(id="P9-1", phase="P9", stage=22, repo="main", model="opus", effort="high",
  title='B：第 0 步秒級——出包把維運指令另放包旁 ops.tar（秒級抽）、第 0 步優先讀它、維護頁隨第 0 步換成新版（A 主機半）',
  ren='extend：`scripts/build/build_bundle.sh`（壓完 tar.gz 後另產 `guidant-ai-<版>.ops.tar.gz`：VERSION／guidantai／guidantai_bugsink.sh／lib／release-keys／maintenance/，並把它的 sha256 寫進 release.json 新欄 `ops_sha256`）、`scripts/build/release_json.sh`（多 `ops_sha256`／`ops_size`）、`lib/self_replace.sh`（`sr_extract` 先找包旁 ops.tar、驗 sha 後抽；沒有才退回從整包抽）、`lib/upgrade_runner.sh`（`upg_self_replace_limit` 有 ops.tar 時 60 秒）、`app/product_update/service/package_downloader.py`（下載路徑多拿 ops.tar，缺就略過）',
  edit=['scripts/build/build_bundle.sh', 'scripts/build/release_json.sh', 'scripts/installer/lib/self_replace.sh', 'scripts/installer/lib/upgrade_runner.sh', 'scripts/installer/lib/upgrade_common.sh', 'app/product_update/service/package_downloader.py', 'app/product_update/service/update_site_client.py', 'test/shell/upgrade_skeleton.bats', 'test/shell/release_json.bats'], new=[],
  deps=[], why="待辦 1 根治＋31：第 0 步 2 分 19 秒全花在 gzip 讀完 5G 才抽到幾個小檔；維運指令另放幾 MB 的小檔秒級抽完，第 0 步就沒東西好修、也不再需要時限繞道。維護頁靜態檔一起放進 ops.tar，第 0 步順手換到前門掛載目錄，升級中畫面當次就是新版（A 的主機半，FE 半在 P9-3）",
  accept='bats：①有 ops.tar 時 `sr_extract` 不讀整包（用 5GB 假 tar.gz＋真 ops.tar，第 0 步 <10 秒）；②ops.tar sha 不符拒抽、退回整包路徑；③沒 ops.tar 的舊包照舊；④release.json 多兩欄且 `release_keys_canonical` 驗得過。`build_bundle.sh --dry-run` 印出 ops.tar；shellcheck 綠'),
dict(id="P9-2", phase="P9", stage=22, repo="main", model="opus", effort="medium",
  title='D：快速出包——build_all 加 --reuse-images 跳過編譯與四顆 image 只重打包；三道 CI 專屬前提搬進腳本；版號烙印不進 Nuitka 讓快取命中',
  ren='extend：`scripts/build/build_all.sh`（`--reuse-images <版>`：docker 給既有 be／fe／init 重新打 tag 到新版號、跳過 build_release／smoke／四支 build_*_image，直接 build_bundle；自己先 `export PATH=.venv/bin`、`assert_db_current` rc 3 走 `smoke_tempdb`、缺 `GUIDANT_BUGSINK_ADMIN_PASSWORD` 一開始就紅）、`scripts/build/build_release.sh`（版號改由 `/api/1.0/version` 讀 dist 旁 `VERSION` 檔，不烙進 C 碼；`--product-version` 固定主版）、`config/version_bake.py` 產生器改只烙 commit',
  edit=['scripts/build/build_all.sh', 'scripts/build/build_release.sh', 'scripts/gen_version_bake.py', 'scripts/build/README.md'], new=[],
  deps=[], why="待辦 12：出一包 30 分，第二包只換版號不該再編 12 分、再匯 10 分；今晚手跑出包撞三道只寫在 .gitlab-ci.yml 的前提，無人值守要能跑就得搬進腳本。版號烙進 C 碼是 Nuitka 快取每版全失效的主因（build_release.sh 檔頭註解已寫明）",
  accept='188 不碰：本機 `build_all.sh --reuse-images 1.27.2 --version 1.27.3 --dry-run` 印出「重打 tag 三顆、略過編譯、略過 image、直接打包」；bats 新 case 驗三道前提缺一即紅且訊息指明缺什麼；`gen_version_bake.py --print` 不含 VERSION 常數、`/api/1.0/version` 單元測試改讀 VERSION 檔；shellcheck 綠'),
dict(id="P9-3", phase="P9", stage=22, repo="fe", model="opus", effort="high",
  title='A FE 半＋待辦 30／31／33：更新中心步驟補第 0 步、預檢搬進確認視窗並比對版本、停機不寫秒數、維護頁亮色與跟主系統主題',
  ren='extend：`productUpdateState.js`（`STEP_NAMES` 首列 `self_replace`；`preflightResult(progress, from, to)` 版本對不上回 null；`estimatedDowntimeSeconds` 改回 null 讓畫面走「會看到維護頁」）、`UpdateCenter.vue`（拿掉常駐「更新前檢查」展開區）、`UpdateConfirmDialog.vue`（放 `PreflightCard`，沒結果顯示「按下更新時第一步會跑」）、`public/maintenance/maintenance.js`（讀 `localStorage.cm_color_scheme` 設 `data-theme`，沒有跟 `prefers-color-scheme`）、`maintenance.css`（補 `:root[data-theme="light"]` 一套 token，值照 `_theme-overrides.scss` light 段）',
  edit=['src/views/product-update/productUpdateState.js', 'src/views/product-update/UpdateCenter.vue', 'src/views/product-update/UpdateConfirmDialog.vue', 'src/views/product-update/PreflightCard.vue', 'src/views/product-update/__tests__/productUpdateState.spec.js', 'public/maintenance/maintenance.js', 'public/maintenance/maintenance.css', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json'], new=[],
  deps=[], why="待辦 30（預檢顯示上一次的舊結果、位置不對、停機 3 秒誤導）、31（更新中心步驟漏第 0 步、按下後 3 分鐘畫面不動）、33（維護頁只有暗色）全在更新中心頁與維護頁兩組檔，一張卡一次改；維護頁改完由 P9-1 的 ops.tar 帶上主機、第 0 步換上，當次升級就看得到",
  accept='vitest 全綠；新 case：preflightResult 對 from／to 不符回 null、STEP_NAMES[0]==self_replace；本機假進度檔驗維護頁亮暗兩色各一張截圖（`localStorage.cm_color_scheme=light` 時白底）；確認視窗有預檢結果區；頁面無常駐「更新前檢查」；`grep border-left` 0'),
dict(id="P9-4", phase="P9", stage=22, repo="agent", model="opus", effort="medium",
  title='Agent 開一支唯讀端點 GET /update/progress 回升級逐步進度（含下載量），主系統主動來拉；心跳不改',
  ren='extend：`guidant_agent/sync/api/update_notify.py` 同一個 blueprint 多掛 `GET /update/progress`（守門沿用 `/update` 前綴＋綁 op，與 notify 同級）；handler 呼叫 `core/update_sync.py` 新增的 `current_progress(cfg)`：下載中讀 `order_dir()` 的 `.part` 大小與升級單 size → `{phase:"download", bytes_done, bytes_total, target}`；主機進度檔 `upgrade-progress.json` 存在且 `job_id` 對得上升級單 → `{phase:"host", status, steps:[{n,name,status,started,ended}], target}`（照抄既有 `steps[]`，不改名）；兩者都不是回 `{phase:"idle"}`。**心跳 payload 與頻率都不動、不碰 `enroll.py`**',
  edit=['guidant_agent/sync/api/update_notify.py', 'core/update_sync.py', 'test/test_update_sync.py'], new=[],
  deps=[], why="待辦 32（決策者：至少讓 user 知道現在跑去哪了）／25：Agent 主機進度檔早有逐步 steps[]，只是心跳只帶最終結果。決策者 2026-10-31 裁：不要再往心跳塞、不要靠 Agent 回報，主系統走既有 FR-039 入站通道（8443 mTLS，notify／credentials 已在用）主動拉；Agent 這半獨立一張（repo 不同），主系統拉的那半在 P9-6",
  accept='pytest `test_update_sync.py`：下載中 `current_progress` 回 phase=download 帶 bytes；主機進度檔 running 回 phase=host 帶 steps；非升級期間回 idle；端點測試：沒帶 op 綁定 JWT 打 `GET /update/progress` 被 `data_plane_auth` 擋 401／403、帶對的回 200 JSON；心跳 payload 與 `heartbeat_wake` 呼叫次數與改前一致（突變：拿掉端點測試要紅）；六支守衛綠'),
dict(id="P9-5", phase="P9", stage=22, repo="jedi-packages", model="sonnet", effort="medium",
  title='幽靈選單根治：jedi-iam 001 不再插 role-manage、jedi-detection 005 改名後不再插 tool-plugin-manage；各發一版',
  ren='extend：`jedi-iam/jedi_iam/migrations/001-iam-ui-routes.sql`（拿掉 role-manage 的 INSERT 與第 51 行起的 route_capabilities 綁定，其餘四選單不動）、`jedi-detection/jedi_detection/migrations/005-detection-ui-routes.sql`（tool-plugin-manage 的 `WHERE NOT EXISTS` 加 `AND NOT EXISTS (name=\'agent-tool-credentials\')`，綁權限那段同）；jedi-iam 1.11.0→1.11.1、jedi-detection 1.7.0→1.7.1，發 Nexus（決策者 2026-10-31 常態授權）',
  edit=['jedi-iam/jedi_iam/migrations/001-iam-ui-routes.sql', 'jedi-iam/pyproject.toml', 'jedi-detection/jedi_detection/migrations/005-detection-ui-routes.sql', 'jedi-detection/pyproject.toml'], new=[],
  deps=[], why="待辦 28／29：全新安裝時套件在主線之後跑，以舊名 NOT EXISTS 把主線已改名／刪除的選單插回；已套環境登記為已套不重跑，改內容只影響新裝。主專案清理那一輪的保險 DELETE 在 P9-7",
  accept='兩套件各自 pytest 綠；`grep -c role-manage 001-iam-ui-routes.sql` 為 0；005 的兩段都含 agent-tool-credentials 條件；版號 bump、`poetry publish -r nexus` 成功、Nexus 查得到新版；回寫列版號與 consumer（主專案 P9-7 改釘）'),
dict(id="P9-6", phase="P9", stage=23, repo="main", model="opus", effort="medium",
  title='主系統主動拉 Agent 升級進度：overview 對下發中那幾台並行打 GET /update/progress，回給畫面；不加表、不改心跳。FE 每台展開 8 格另開 fe 卡',
  ren='extend：`infra/remote_agent/agent_update_notify_client.py` 多一支 `fetch_progress(agent) -> Optional[dict]`（同 `send` 通道、`GET /update/progress`、3 秒逾時、連不到／指紋不符／非 2xx 回 None 只記 debug）；`app/product_update/service/agent_rollout_service.py` `overview()` 對 rollout 狀態 `dispatched`（或既有「進行中」集合）的台用既有 `_pool` 並行拉，拉到就塞進 `_agent_item` 的 `progress`，拉不到回上一次成功拉到的值（service 內 `{agent_uid: (at, progress)}` 記憶體快取，心跳帶 `upgrade_result` 最終結果時清掉）；**不加欄位、不加 migration、不改 observer**。FE 半（`agentRolloutState.js` `agentStepRows`＋`AgentRolloutPanel.vue` expander 8 格重用 `UpgradeStepList`）**不在本卡**，首腦另開 fe 卡放同批',
  edit=['infra/remote_agent/agent_update_notify_client.py', 'app/product_update/service/agent_rollout_service.py', 'test/test_fr167_agent_rollout.py'], new=[],
  deps=["P9-4"], why="待辦 32 主系統半。決策者 2026-10-31 裁改拉：Agent 入站 8443 是安裝必填（`AGENT_BASE_URL` 寫進憑證），notify／設帳密早就走這條，加一支 GET 就有即時進度，不必加表、不必改心跳、不必等 Agent 回報；FE 一卡一 repo 另開，本卡不再「開工問首腦」",
  accept='pytest：overview 對 dispatched 台呼叫 `fetch_progress`（mock）並把回傳塞進 `progress`；非進行中的台不呼叫；fetch 回 None 時沿用快取值；`upgrade_result` 進來後快取清掉（突變：拿掉清快取那行要紅）；`fetch_progress` 對 4xx／逾時回 None 不 raise；守衛綠；實打 DEV `GET /system/product-update/agents` 回 200'),
dict(id="P9-7", phase="P9", stage=23, repo="main", model="sonnet", effort="medium",
  title='主專案改釘 jedi-iam 1.11.1／jedi-detection 1.7.1、重攤平套件 migration；清理那一輪補刪 tool-plugin-manage；出包前加「全新安裝幽靈選單比對」；升級成功後只留安裝包不留解開目錄',
  ren='extend：`pyproject.toml`（兩支改釘）＋`poetry update jedi-iam jedi-detection`＋`scripts/build/flatten_pkg_migrations.sh` 重跑、`scripts/init/migrate-capability-grants.sql` (e) 段加 `DELETE FROM ui_routes WHERE name=\'tool-plugin-manage\' AND EXISTS(agent-tool-credentials)`、`lib/upgrade_cleanup.sh` `cln_packages` 本次解開目錄也刪（只留 tar.gz＋release.json）；`scripts/build/build_all.sh` 在 `build_bundle` 成功回 0 後加 `docker builder prune -f --keep-storage 20G`（只留最近 20G；失敗不跑；`--no-prune` 給連出兩包的場合；**只准 `builder prune`，不准 `system`／`image prune`**，第三方映像與 dist 會重載、決策者明示不清）；new：`scripts/build/assert_fresh_install_menus.sh`（用出貨 init image 空庫 init＋migrate，`ui_routes` 對照 `scripts/build/expected_menus.txt`，多出來的列出並紅）掛進 `build_bundle.sh` 前置檢查',
  edit=['pyproject.toml', 'poetry.lock', 'scripts/sql/packages/manifest.tsv', 'scripts/init/migrate-capability-grants.sql', 'scripts/installer/lib/upgrade_cleanup.sh', 'scripts/build/build_bundle.sh', 'scripts/build/build_all.sh', 'scripts/build/README.md', 'test/shell/upgrade_cleanup.bats', 'test/shell/build_all.bats'], new=['scripts/build/assert_fresh_install_menus.sh', 'scripts/build/expected_menus.txt'],
  deps=["P9-5"], why="待辦 28／29 的主專案半（接 P9-5 發的版）＋磁碟（每台多佔 8.4G 解開目錄；188 每包多堆 8G 死 build cache，決策者 10/31 裁根治——原寫在 P9-2，小弟 11:50 做完後才追加、沒看到，移到這張）＋「以後不再一個一個冒出來」（決策者：一次收乾淨）。改 `scripts/sql/packages/manifest.tsv`（攤平產物）與 `build_bundle.sh`，與 P9-1 不同批",
  accept='`build_all.sh --dry-run` 多印「收尾 prune --keep-storage 20G」、`--no-prune` 不印；bats 一 case：bundle 樁回非 0 時 prune 不被叫；本機：出貨 init image 空庫 init＋migrate 後 `assert_fresh_install_menus.sh` 綠、故意塞一列幽靈選單時紅並列出名字；`ui_routes` 無 role-manage／tool-plugin-manage；bats cleanup 新 case「成功後 bundles/ 本次目錄被刪、incoming/ 本次 tar.gz 留」；守衛 119 綠；回寫「出貨基線待重產」'),
dict(id="P9-10", phase="P9", stage=23, repo="fe", model="opus", effort="medium",
  title='FE 半：每台 Agent 下展開 8 格升級步驟（重用 UpgradeStepList，吃 P9-6 回的 progress）；證據上傳 accept 讀 BE 白名單端點＋P9-9 新錯誤碼翻譯',
  ren='extend：`src/views/product-update/agentRolloutState.js` 加 `agentStepRows(agent)` 把 overview 每台的 `rollout.progress`（P9-6 回：`{phase, target, bytes_done, bytes_total, status, steps[]}`）組成 8 格 rows 給既有 `UpgradeStepList`（它已收 `rows` prop，不另刻）；`AgentRolloutPanel.vue` DataTable 加 `expander` Column＋`#expansion` slot 放 `<UpgradeStepList :rows>`，狀態 dispatched 那列預設展開（`expandedRows` 跟 `rolloutStatus` 連動）；new `src/composables/useAcceptedUploadTypes.js`（打 P9-9 的 `GET /upload-file/accepted-types`，快取一次，回 `accept` 字串）套到五個證據上傳入口的 `<FileUpload accept>`／`<input accept>`：`JobExecutionDrawer.vue`、`ar-result/ArEvidenceList.vue`、`ssp/SspTabPanel.vue`、`ControlSuggestionAttachments.vue`、`ModuleFrameDocumentPoolPanel.vue`（開工先 grep `type="file"\|<FileUpload` 對一次清單）；`error-code.json` zh-tw／en 補 P9-9 新增的 UPLOAD 錯誤碼（中文照 BE `common/code/error_code.py` 取）',
  edit=['src/views/product-update/agentRolloutState.js', 'src/views/product-update/AgentRolloutPanel.vue', 'src/__tests__/agent-rollout-state.spec.js', 'src/components/grc/JobExecutionDrawer.vue', 'src/components/grc/ar-result/ArEvidenceList.vue', 'src/components/grc/ssp/SspTabPanel.vue', 'src/components/grc/ControlSuggestionAttachments.vue', 'src/components/grc/ModuleFrameDocumentPoolPanel.vue', 'src/config/api/api.js', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json', 'src/config/locales/i18n/zh-tw/error-code.json', 'src/config/locales/i18n/en/error-code.json'], new=['src/composables/useAcceptedUploadTypes.js'],
  deps=["P9-6", "P9-9"], why="P9-6 與 P9-9 都有 FE 半，一卡一 repo 不能塞進 BE 卡（CM-3343 實踩停下問）；兩個 FE 半都小、都接批 22／23 的 BE 契約，合成一張放批 23（P9-6 同批，契約以 P9-6 卡上寫的 progress 形狀為準，BE 沒好先用假資料做）",
  accept='vitest 全綠；新 case：`agentStepRows` 對 dispatched 無 progress → 第 0 格 now 其餘 wait；phase=download → 第 1 格 now 帶 bytes 文字；phase=host 的 steps 映射到 2～7 格、failed 格 fail 帶原因；succeeded 全 done。假 overview 下 Agent 區展開截圖一張（下發中那台預設展開、8 格可見）；五個上傳入口 `accept` 都非空且來自同一 composable（grep 不得出現寫死的 ".pdf,.docx" 字串）；`error-code.json` 兩語系含 P9-9 新碼；`grep border-left` 0；`vite build` 0 錯'),
dict(id="P9-11", phase="P9", stage=22, repo="fe", model="sonnet", effort="medium",
  title='FE Dockerfile 分層：npm install 獨立一層放在 COPY . . 之前、只改程式不重裝套件；加 .dockerignore（排 node_modules／dist／package-lock.json，不排 .git）',
  ren='extend：`Dockerfile`（`COPY package*.json` → `RUN --mount=type=secret,id=npmrc …  npm install` → `COPY . .` → `RUN npm run build:${SERVICE_ENV}`；`NEXUS_REGISTRY` ARG 留 install 前，`SERVICE_ENV`／`GIT_COMMIT` ARG＋ENV 搬到 install 後，既有三段陷阱註解只搬位置不刪；補一行註解「install 層快取住後 package.json 不改就不會撿新小版本，要重解析用 build_fe_image.sh 的 NO_CACHE」）；new `.dockerignore`（node_modules、dist、package-lock.json、.vite；**不排 .git**）。不動 BE `scripts/build/build_fe_image.sh` 與 nginx 第二階段',
  edit=['Dockerfile'], new=['.dockerignore'],
  deps=[], why="另一 session 清 188 磁碟時發現：Dockerfile 的 COPY . . 在 npm install 前、install 與 build 同一個 RUN，任何程式改動都讓快取失效、每次重載全部套件，188 每 build 多吃約 1.8G build cache，也把本機 819M node_modules 送進 context。與 P9-2 快速出包同一個目標（出包別做白工），改一支 Dockerfile 規格已定，sonnet 夠",
  accept='本機 docker 連續 build 兩次（`DOCKER_BUILDKIT=1 docker build --build-arg SERVICE_ENV=ONPREM --build-arg NGINX_CONF=nginx.onprem.conf .`，有 ~/.npmrc 就照 Dockerfile 註解掛 secret）：第一次完成；改一個 .vue 一行字第二次 log 的 npm install 步標 CACHED、只重跑 build；改 package.json 一行後 install 步重跑；最終 image `/usr/share/nginx/html/index.html` 存在；`docker history` 看不到 node_modules 層被 COPY . . 帶進去。回寫附兩次耗時與 CACHED 那行'),
dict(id="P9-8", phase="P9", stage=24, repo="main", model="opus", effort="high",
  title='結案：本機驗新裝與升級（含幽靈選單比對）→ 版號 1.27.3／Agent 1.4.6 → 188 出正式驗證包 → 用 --reuse-images 快速出 1.27.4／1.4.7 第二包',
  ren='reuse：P9-2 的 `build_all.sh --reuse-images`、既有 init image 驗法、`build_bundle.sh`。小弟做到 188 出兩包；簽清單／發布更新站／190 升級／驗收由首腦做',
  edit=['pyproject.toml'], new=[],
  deps=["P9-1", "P9-2", "P9-3", "P9-4", "P9-5", "P9-6", "P9-7", "P9-10", "P9-11"], why="決策者 2026-10-31：本體與 Agent 都出兩包，第二包只換版號用快速模式；190 先升 1.27.3（驗 P9 全部修正中「平常畫面」與主機側），再升 1.27.4（驗第 0 步秒級、維護頁當次換新、自動跳轉、亮色）。做完 A 之後這是最後一次需要第二包",
  accept='188 `.build/bundle/` 有 1.27.3 與 1.27.4 兩包＋sha256＋release.json＋ops.tar；1.27.4 出包時間 <15 分且 log 顯示「重打 tag、略過編譯」；兩包開包 grep 皆含 P9-1 的 ops 路徑與 P9-7 的 DELETE；Agent compose 1.4.6／1.4.7 兩包；本機新裝 0 錯誤＋幽靈選單比對綠、升級 0 錯誤；`audit_deps.sh` 0 項；回寫兩包路徑與 sha256'),
dict(id="P9-9", phase="P9", stage=22, repo="main", model="opus", effort="medium",
  title='證據上傳只收文件與圖片：副檔名＋檔頭白名單（壓縮包、影片、執行檔擋下），前後端同一份清單',
  ren='extend：jedi-file-upload `plugin/contract.py` 已有 `pdf_convertible_exts`／`previewable_exts` 兩個 port，**加第三個 `uploadable_exts`** 與 `DEFAULT_UPLOADABLE_EXTS`；主專案 `app/upload_file/service/managed_file_upload_service.py` `upload_files_for_tenant` 入口加一道 `validate_upload_type(file)`（副檔名在白名單 ＋ 讀檔頭 magic 與副檔名一致；沿用 `preview_policy.read_head`／`_MAGIC`，Office 用 zip 簽章＋內部 `[Content_Types].xml` 判、純文字類驗 UTF-8 可解且無 NUL），不合回 `UPLOAD_400xxx` 新錯誤碼；FE 證據上傳 `<input accept>` 由 BE `GET /upload-file/accepted-types` 讀同一份清單——**FE 半（accept＋錯誤碼翻譯）在 P9-10（fe，批 23），本卡只做 BE 與套件 port，不問首腦**。new：`common/code/upload_error_code.py` 加碼',
  edit=['app/upload_file/service/managed_file_upload_service.py', 'common/code/error_code.py', 'config/config.py', 'test/test_managed_file_upload_adapter_selection.py'], new=['app/upload_file/service/upload_type_policy.py', 'test/test_upload_type_policy.py'],
  deps=[], why="決策者 2026-10-31：證據上傳只能文件或圖片，zip 先不給、影片沒預覽也先限制。實查：BE 沒有任何類型關卡、FE input 沒 accept，任何檔都收；資安規則 uploads-parsing.md SR-04.4／04.6 本來就要求副檔名白名單＋伺服器讀檔頭，這卡是落實不是新政策。影片播放器留第二期",
  accept='pytest：白名單內 24 種各一檔通過；zip／mp4／exe／sh 擋下回新錯誤碼；改名成 .png 的 zip 擋下（檔頭不符）；改名成 .docx 的 html 擋下；svg 與 html 一樣標 sandbox 預覽；DEV 8003 實打上傳一張 png 過、一個 zip 412；`accepted-types` 端點回清單'),
dict(id="P10-1", phase="P10", stage=25, repo="main", model="opus", effort="medium",
  title='下載完到遮罩之間的空窗：寫單前不重算整包雜湊、「更新到 X」建的下載單就緒後再按回 412（BE 半）',
  ren='extend：`app/product_update/service/product_update_flow.py` `run_download` 下載完已 `verify_package_file` 過，把「已驗過」事實傳給 `upgrade_order._enqueue`（加參數 `verified_path: Optional[list]=None`，有就跳過 `verify_ready_path`；其他 caller 不傳照舊驗）；`upgrade_order.apply` 開頭加判斷：最新一列 `ready` 且 `applied_by` 有值、`cancelled_at` 空、`error` 空 → 是「更新到 X」建的、正在等寫單，拋 `PreconditionFailedError(PRODUCT_UPDATE_ALREADY_SUBMITTING)`（新錯誤碼 412 系列，`common/code/product_update_error_code.py`）。主機側撿單與預檢各算一次雜湊這次不動（兩邊各自獨立驗是刻意的）',
  edit=['app/product_update/service/product_update_flow.py', 'app/product_update/service/upgrade_order.py', 'common/code/product_update_error_code.py', 'test/test_fr167_upgrade_order.py', 'test/fixtures_frozen_error_codes.py'], new=[],
  deps=[], why="決策者 2026-10-31 18:25 在 190 看到：下載 100% 後畫面退回就緒卡、停 37 秒才蓋遮罩，期間「更新到 X」仍可再按。第 7 棒用檔案 mtime＋DB 列＋journal 對出：18:17:00 下載完 → 18:17:17 算完第 1 次整包 sha256 寫 ready → 18:17:37 寫單前又算第 2 次（`_enqueue`→`verify_ready_path`）才變 scheduled。第 2 次純屬多餘；再按會進同一條寫單路徑兩邊搶。FE 半在 P10-2",
  accept='pytest `test_fr167_upgrade_order.py`：①`run_download` 完成後 `_enqueue` 不再呼叫 `verify_package_file`（mock 計數 1 次不是 2 次）；②`apply` 對「ready＋applied_by 有值」回 412 新碼、對「ready＋applied_by 空」照舊寫單、對「ready＋cancelled_at 有值」照舊寫單（取消後重按要能用）；③突變：把 `verified_path` 判斷拿掉 → ①紅。DEV 8003 實打：按「更新到 X」→ 下載完 → 立刻再按 → 412；守衛綠'),
dict(id="P10-2", phase="P10", stage=25, repo="fe", model="opus", effort="medium",
  title='更新中心四個畫面修正：下載 100% 即蓋遮罩、側欄每次載入重抓、product-update 能力點翻譯、Agent 目標版未知不顯示「已是最新」',
  ren='extend：`productUpdateState.js` `deriveView`：`job.status==="ready"` 且 `job.applied_by` 有值、`!job.error`、`!job.cancelled_at` → 回 `submitted`（遮罩從 100% 那刻蓋上，`pollIntervalMs` 已會繼續問）；`userStore.js` 加 `ensureWebMenu()`（比照 `ensureCapabilities` 的「本次頁面載入抓過就不抓」旗標，打 `API.WEB_MENU` 寫 `permissions`），`App.vue` `onMounted` 呼叫一次；`permission-tree.json` zh-tw／en 三段（`capability.system`／`permission_resource.system`／`capability_how.system`）各補 `product-update` 的 read／update（中文取 BE `core/host_capabilities.py:214-215`）；`AgentRolloutPanel.vue` 第 89 行狀態標籤：`a.lagging === null`（BE P10-6 會回 null 表示目標版未知）→ 顯示 `tag_unknown`「無法比對」灰標，且 `bundleReady=false` 時卡片說明改成「這一版的 Agent 安裝包不在升級目錄（用安裝包手動升級時不會放進去；下一次走線上更新即可）」；`doneCount` 排除 null。i18n 兩語言補 `tag_unknown`、`agent_bundle_missing_hint`',
  edit=['src/views/product-update/productUpdateState.js', 'src/views/product-update/__tests__/productUpdateState.spec.js', 'src/stores/userStore.js', 'src/App.vue', 'src/config/locales/i18n/zh-tw/permission-tree.json', 'src/config/locales/i18n/en/permission-tree.json', 'src/views/product-update/AgentRolloutPanel.vue', 'src/views/product-update/agentRolloutState.js', 'src/__tests__/agent-rollout-state.spec.js', 'src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json'], new=[],
  deps=[], why="四個都是決策者 2026-10-31 升 188／189 到 1.27.7 後親眼看到的（STATE 待辦 44／45／46／51）：空窗期按鈕可按、側欄殘留 `lang.menu.tool-plugin-manage`（伺服器乾淨、瀏覽器 localStorage 舊存檔）、角色頁印原始 key、Agent 1.4.2 顯示「已是最新」（目標版空被判不落後）。全是 FE 小改、同 repo，合一張",
  accept='vitest 全綠；新 case：`deriveView` 對 ready＋applied_by → submitted、ready 無 applied_by → ready、ready＋cancelled_at → ready；`agentRolloutState` 對 lagging=null 的台不算 done、不可選；`ensureWebMenu` 同頁第二次呼叫不再打 API（spy）。手測：本機 vite 接 DEV 8003，F5 後側欄立刻反映 DB 現況（先用 DEV 改一筆 ui_routes enable 驗）；角色頁更新中心兩個能力點顯示中文；`grep border-left` 0；`vite build` 0'),
dict(id="P10-3", phase="P10", stage=26, repo="main", model="opus", effort="medium",
  title='拿掉發布清單 90 天有效期（決策者裁）；升級預檢加「核心裝了沒重開」檢查＋手冊',
  ren='extend：`app/product_update/service/package_verifier.py` 刪 `MANIFEST_VALID_DAYS`／`EXPIRED`／`verify_manifest` 的有效期分支（`issued_at` 欄位保留、照舊解析但不判）；`common/code/product_update_error_code.py` `PRODUCT_UPDATE_MANIFEST_EXPIRED` 標 deprecated 不刪（FE i18n 有對應鍵）；`test_fr167_package_verifier.py` 刪過期 case、加「issued_at 一年前仍收」case；`scripts/installer/lib/release_keys.sh` 若有同條日期檢查一併拿（先 grep `issued_at`）。預檢：new `scripts/installer/lib/upgrade_preflight_host.sh`（`pf_check_kernel`：`/var/run/reboot-required` 存在、或 `uname -r` 版本 < `dpkg -l linux-image-*` 最新已裝版 → 紅並指路「請先重開機」；`docker run --rm --security-opt no-new-privileges --entrypoint true <現行 be image>` 起不來也紅），`upgrade_preflight.sh` 只加一行 source＋呼叫（它已超標只准一行）。手冊 `docs/user-manual/onprem/05-upgrade-rollback.md` 與 `01-prerequisites.md` 各加一段「升級前把 apt 更新到底並重開一次」；`08-network.md` 那段「每日 03:00」改「每 6 小時」',
  edit=['app/product_update/service/package_verifier.py', 'common/code/product_update_error_code.py', 'test/test_fr167_package_verifier.py', 'scripts/installer/lib/release_keys.sh', 'scripts/installer/lib/upgrade_preflight.sh', 'docs/user-manual/onprem/05-upgrade-rollback.md', 'docs/user-manual/onprem/01-prerequisites.md', 'docs/user-manual/onprem/08-network.md'], new=['scripts/installer/lib/upgrade_preflight_host.sh', 'scripts/installer/test/upgrade_preflight_host.bats'],
  deps=[], why="批 26（與 P10-1 同改登記型檔 `product_update_error_code.py`，不同批）。90 天：決策者 2026-10-31 20:00 裁「就 1（拿掉）」——客戶拖三個月或離線檔放一年就裝不了、原廠得每 90 天重蓋章；重放舊版已由版號遞增＋主版號＋主機公鑰目錄三道擋住。核心檢查：待辦 38，188／189 10-31 都因 apt 裝了新核心沒重開讓 docker seccomp errno 524、容器全起不來，預檢沒擋、升級會在第 4 步炸；修升級程式與手冊同一張",
  accept='pytest `test_fr167_package_verifier.py` 全綠且含「issued_at 400 天前仍 accept」；突變：把日期分支加回去 → 該 case 紅。bats（ubuntu 容器）：reboot-required 存在 → 預檢紅帶「請先重開機」；uname 版 < dpkg 最新版 → 紅；都正常 → 綠；nnp 容器起不來 → 紅。`grep -rn "90 天\|MANIFEST_VALID_DAYS" app/ scripts/installer/` 為 0。`upgrade_preflight.sh` `wc -l` 不增加超過 2 行。手冊三處 grep 到新句'),
dict(id="P10-4", phase="P10", stage=25, repo="main", model="sonnet", effort="medium",
  title='出包 storage-seed 每次從套件 assets 重建、筆數對不上就擋（CRA PDF 沒進包的根因）',
  ren='extend：`scripts/build/build_bundle.sh` ⑥ 段：拿掉「已存在且非空時不動」——每次 `rm -rf .build/storage-seed && cp -a .build/pkg-assets/. .build/storage-seed`；加守門：各套件 `assets/storage-seed.map` 非註解行數總和 ≠ `.build/storage-seed` 檔數 → `fail`（列出缺哪支 key）；`flatten_pkg_migrations.sh` 攤平段同樣在結尾核數並印清單。順手把 README「storage-seed 產製 SOP」那段改成現況（來源是套件 wheel，不再手產）',
  edit=['scripts/build/build_bundle.sh', 'scripts/build/flatten_pkg_migrations.sh', 'scripts/build/README.md', 'scripts/init/README.md'], new=['scripts/build/test/storage_seed_guard.bats'],
  deps=[], why="決策者 2026-10-31 18:55 在 188 CRA 框架頁看到「原始 PDF 檔已遺失」。第 7 棒查：1.27.7 包 storage-seed 只有 10 檔、少 CRA 那支；188 `.build/storage-seed/` 是 8/21 舊殘留，`build_bundle.sh` 寫「已存在就不動」讓它贏過 10/30 攤平的 11 檔（含 CRA）。套件 map 三行都對，問題只在 build 機殘留＋腳本沒守門。188 清殘留與三台補灌由首腦做",
  accept='本機：先塞一個舊的 `.build/storage-seed/`（少一檔）再跑 `build_bundle.sh` 到 ⑥ 段（或抽成函式單測）→ 重建成 11 檔；故意從 `.build/pkg-assets` 刪一檔 → 紅並列出缺的 key；bats 兩 case。`scripts/build/README.md` 與 `scripts/init/README.md` grep 不到「手產」「殘留」這類舊 SOP。不碰 188'),
dict(id="P10-5", phase="P10", stage=25, repo="jedi-packages", model="opus", effort="medium",
  title='jedi-oscal-v2：刪 CRA 繁中舊版本列（deprecated、011 已併進翻譯表）＋框架版本清單一律濾 deprecated；發 2.9.1',
  ren='new：`jedi_oscal_v2/migrations/027-cra-2024-drop-deprecated-zh-tw-version.sql`（定位用 007 寫死的 uid `c7a1e2d3-…`／catalog uuid `b8c2f3e4-…`；先驗：沒有任何 `oscal.profiles`／resource library／project catalog 的 `source_catalog_id`／`framework_version_id` 指到它（011 已改掛英文版 `d2170abf-…`），有就 RAISE NOTICE 跳過不刪；沒有才刪 framework_version → catalog 整棵（controls／parts／groups／metadata，照 catalog 既有的刪除順序，先 grep 套件有沒有 `delete_catalog` 可 reuse）；冪等）。extend：`jedi_oscal_v2/app/service/framework/framework_service.py` 版本清單／下拉的查詢加 `publish_status != deprecated`（找所有回 framework_versions 列表的方法，不只一支）。bump 2.9.1、`poetry publish -r nexus`',
  edit=['jedi-oscal-v2/jedi_oscal_v2/app/service/framework/framework_service.py', 'jedi-oscal-v2/pyproject.toml', 'jedi-oscal-v2/jedi_oscal_v2/assets/storage-seed.map'], new=['jedi-oscal-v2/jedi_oscal_v2/migrations/027-cra-2024-drop-deprecated-zh-tw-version.sql', 'jedi-oscal-v2/tests/test_027_drop_deprecated_cra_zh.py'],
  deps=[], why="決策者 2026-10-31 20:46：CRA `2024/2847-zh-TW` 不該出現。第 7 棒查：全新安裝沒有（基線庫、DEV、init seed 只有英文版）；升級路徑 007→011 留一列 deprecated 不刪（怕有資源庫掛著），188／189／190 三台都有，前端清單沒濾。兩手都做：刪（驗無掛載）＋濾（保險）。主專案改釘在 P10-7",
  accept='套件 tests 全綠；新測試：①有 profile 掛在中文 catalog → 027 跳過、列還在；②無掛載 → 刪乾淨（framework_versions／catalogs／metadata 都查不到那兩個 uuid）；③重跑不報錯；④清單方法對 deprecated 列不回。本機 DEV 庫（有 007 列的話）套 027 一次並 `SELECT` 核。Nexus 上 2.9.1 存在。`storage-seed.map` 不動（只是確認三行仍在）'),
dict(id="P10-6", phase="P10", stage=27, repo="main", model="opus", effort="medium",
  title='主系統補打更新站 /v1/report（升級終態回報）；Agent 目標版未知時 lagging 回 null（BE 半）',
  ren='extend：`app/product_update/service/update_site_client.py` 加 `report(license_text, fingerprint, ticket, result, from_version, to_version, at)`（`_post("/v1/report", …)`，body 形狀照 update-center `src/routes/client.ts` `handleReport` 讀的欄位，開工先讀那支；失敗只 `logger.warning` 不 raise）；`upgrade_order.sync_from_progress` 寫回終態（succeeded／failed／rolled_back／skipped）那一刻呼叫一次（背景、不擋 status 請求：比照 `after_switch(recheck=True)` 只在每分鐘排程那條路打，status 請求那條不打）；`agent_rollout_service.is_lagging`：`target_version` 為 None → 回 `None`（不是 False），`_agent_item` 的 `lagging`／`required` 跟著可為 null，`_summary` 把 null 不算 done。FE 半在 P10-2',
  edit=['app/product_update/service/update_site_client.py', 'app/product_update/service/upgrade_order.py', 'app/product_update/service/agent_rollout_service.py', 'test/test_fr167_upgrade_order.py', 'test/test_fr167_agent_rollout.py', 'core/scheduler_update.py'], new=[],
  deps=["P10-1"], why="待辦 49：更新站 `/v1/report` 端點與 `instance_version_reports.last_upgrade_*` 都做好了、後台也用它算幾台退回，但 BE client 從沒打過，原廠看不到客戶升級成功或退回。待辦 51 BE 半：目標版空時 `is_lagging` 回 False 讓 1.4.2 顯示「已是最新」。與 P10-1 同檔 `upgrade_order.py`，放下一批",
  accept='pytest：`report` 對 succeeded／rolled_back 各打一次且 body 欄位齊；更新站 5xx／連不到 → 只 warning、`sync_from_progress` 回值不變；status 請求那條路不打 report（spy 0 次）、排程那條路打 1 次；`is_lagging(x, None)` 回 None、`_summary` 不把 null 算 done；突變：把 report 呼叫拿掉 → 紅。DEV 8003 對本機 `wrangler dev` 的 update-center 實打一次 report 200（本機跑，不打真站）'),
dict(id="P10-7", phase="P10", stage=27, repo="main", model="sonnet", effort="low",
  title='主專案改釘 jedi-oscal-v2 2.9.1、重攤平套件 migration（帶進 027）',
  ren='reuse：`pyproject.toml` 改釘 `jedi-oscal-v2==2.9.1`，`poetry update jedi-oscal-v2`（指定套件名、不裸跑），`scripts/build/flatten_pkg_migrations.sh` 重跑，commit `scripts/sql/packages/jedi_oscal_v2/027-…` 與 `manifest.tsv` 變動；DEV 本機套 027 一次（只套 DEV）',
  edit=['pyproject.toml', 'poetry.lock', 'scripts/sql/manifest.tsv'], new=['scripts/sql/packages/jedi_oscal_v2/027-cra-2024-drop-deprecated-zh-tw-version.sql'],
  deps=["P10-5"], why="P10-5 發版後主專案要跟；`pyproject.toml` 與 `manifest.tsv` 是登記型檔，單獨一張、只動這兩處",
  accept='`.venv/bin/pip show jedi-oscal-v2` 回 2.9.1；`scripts/sql/packages/jedi_oscal_v2/` 多 027 且 `manifest.tsv` 有對應列；DEV 套 027 後 `SELECT count(*) FROM oscal.framework_versions WHERE uid=\'c7a1e2d3-…\'` 為 0（DEV 本來就沒有也要跑一次確認不報錯）；守衛綠；`audit_deps.sh` 0 項'),
dict(id="P10-8", phase="P10", stage=28, repo="main", model="opus", effort="high",
  title='結案：本機驗新裝＋升級 → 版號 1.27.8／Agent 不動 → 188 出包（開包驗 storage-seed 11 檔含 CRA PDF）',
  ren='reuse：P9-2 的 `build_all.sh`（本次要重編譯，不用 `--reuse-images`：BE 有程式改動）、`build_bundle.sh`、CM-3344 的本機新裝／升級驗法。小弟做到 188 出包＋開包驗；簽清單／發布更新站／190／188／189 升級／三台 bucket 補灌 CRA PDF 由首腦做',
  edit=['pyproject.toml'], new=[],
  deps=["P10-1", "P10-2", "P10-3", "P10-4", "P10-5", "P10-6", "P10-7"], why="批 25～26 全合回後出 1.27.8，一次帶七條修正；開包必驗 storage-seed 筆數（P10-4 的守門在出包那台才真的跑到）。188 出包前先 `rm -rf /opt/guidant-ai-be/.build/storage-seed`（舊殘留）——P10-4 改完腳本會自己重建，但仍寫進步驟以防小弟用舊腳本",
  accept='188 `.build/bundle/` 有 1.27.8 包＋sha256＋release.json＋ops.tar；解開 `storage-seed/` 恰 11 檔且含 `20261014000000_b81c42ff-….pdf`；開包 grep：`package_verifier` 無 `MANIFEST_VALID_DAYS`、`upgrade_preflight_host.sh` 在 lib、`027-cra-…` 在 init image 的 sql；本機新裝 0 錯誤＋幽靈選單比對綠＋`ui_routes`／`framework_versions` 無 CRA zh-TW 列、升級（從 1.27.7 dump）0 錯誤且 027 跑到；`audit_deps.sh` 0 項；回寫包路徑與 sha256'),
dict(id="P11-1", phase="P11", stage=29, repo="update-center", model="opus", effort="medium",
  title='更新站 /v1/report 收 Agent 的升級結果：report 多 target（host／agent）＋ agent_fingerprint，新表 agent_version_reports，後台版本頁多「Agent 幾台在哪版」',
  ren='extend：`src/routes/client.ts` `handleReport` 多讀 `target`（缺＝host，舊客戶端相容）與 `agent_fingerprint`（target=agent 必填，格式同 FINGERPRINT_RE）；`src/instances.ts` 加 `recordAgentUpgrade(db, licenseId, hostFingerprint, agentFingerprint, from, to, result, now)` 寫新表；new `migrations/0004_agent_version_reports.sql`（license_id、host_fingerprint、agent_fingerprint、current_version、last_upgrade_result／from／to／at、last_seen，PK (license_id, agent_fingerprint)）；`src/releases.ts` 後台版本清單的統計多 `agent_on_version`／`agent_rolled_back`／`agent_failed` 三欄，`routes/admin.ts` 版本頁表格多一欄「Agent」。不改 check／download-url。**不 deploy、不對真 D1 套 migration**（部署由首腦）',
  edit=['src/routes/client.ts', 'src/instances.ts', 'src/releases.ts', 'src/routes/admin.ts', 'test/client.spec.ts', 'test/admin.spec.ts'], new=['migrations/0004_agent_version_reports.sql'],
  deps=[], why="PM 2026-10-31 會議：「Agent 更新的時候也要回傳給 UC 更新到哪一版」。現況更新站 `instance_version_reports` 每台主機一列、`/v1/report` 只收 result／from／to，Agent 升級結果主系統有（`agent_rollout_service.record_upgrade_result`）但送不上去。更新站半先行，主系統半在 P11-6（依賴這張的欄位名）",
  accept='vitest：①舊 body（無 target）照寫 instance_version_reports；②target=agent 缺 agent_fingerprint → 400；③target=agent 寫 agent_version_reports 一列、重送同指紋覆寫不新增；④版本頁統計對兩台 agent 在 1.4.7、一台 rolled_back 回 2／1；本機 `wrangler dev` 對 `--local` D1 套 0004 後實打 report 兩種 body 各 204。回寫 body 欄位名與表結構給 P11-6'),
dict(id="P11-2", phase="P11", stage=29, repo="fe", model="sonnet", effort="low",
  title='更新中心文案：「這一版更新了什麼」改「更新內容」',
  ren='extend：`src/config/locales/i18n/zh-tw/product-update.json` `notes_heading` 改「更新內容」；`en/product-update.json` 同鍵改「What\'s included」。只改這兩行，其他文案不動',
  edit=['src/config/locales/i18n/zh-tw/product-update.json', 'src/config/locales/i18n/en/product-update.json'], new=[],
  deps=[], why="PM 2026-10-31 會議指定；一鍵兩語言，sonnet 夠",
  accept='兩語言 grep `notes_heading` 為新值；vitest i18n 一致性測試綠；本機 vite 接 DEV 看更新中心可用版本卡標題'),
dict(id="P11-3", phase="P11", stage=29, repo="fe", model="opus", effort="low",
  title='支援頁文案精簡：拿掉說明性贅述、只留功能與必要提示、語氣正式',
  ren='extend：`src/config/locales/i18n/zh-tw/support.json`＋`en/support.json` 重寫 `recent_errors_hint`／`export_hint`／`contents_*`／`included_*`／`excluded_*`／`contents_footnote`；`src/views/support/SupportPage.vue` 第 220～240 行「這個包裡有什麼、不會有什麼」整段改成一個可收合的「診斷包內容」`Accordion`（預設收起），正文只留兩個卡片標題與按鈕。`menu.json` 第 280 行選單描述改一句「系統錯誤紀錄與診斷包匯出」。不動 BE、不動能力點',
  edit=['src/config/locales/i18n/zh-tw/support.json', 'src/config/locales/i18n/en/support.json', 'src/views/support/SupportPage.vue', 'src/config/locales/i18n/zh-tw/menu.json', 'src/config/locales/i18n/en/menu.json'], new=[],
  deps=[], why="PM 2026-10-31 會議：「這頁太白話、一堆廢文在底下」；決策者裁：只留有什麼內容、正式、不要贅詞。現況 support.json 每條提示都是一整段解釋（例如 excluded_secrets 連「讓分析的人知道有這個設定但值被遮了」都寫進畫面）",
  accept='每條 i18n 值 ≤ 25 字（中文）／一句話（英文），不含「先看一眼」「交給原廠分析」「您可以」這類口語；`contents_*` 整塊預設收起；截圖兩張（收起／展開）；vitest 綠、`vite build` 0；`grep border-left` 0'),
dict(id="P11-4", phase="P11", stage=29, repo="main", model="opus", effort="medium",
  title='升級期間的進度回寫 system_logs：換接完成後把進度檔每一步寫成稽核事件（只寫一次）',
  ren='extend：`app/product_update/service/upgrade_order.py` 加 `log_steps_once(progress)`（比照 `report_final_once` 的「已記過」旗標，`KEY_LAST_LOGGED` 存 job_id 到 root config）：進度檔 `steps[]` 每步一筆 `logger.info(..., extra={"event_code": PRODUCT_UPDATE_STEP_<n>})`，訊息含步名白話、狀態、起訖時間、detail／error；頂層結果再一筆 `PRODUCT_UPDATE_RESULT`。事件碼在 `common/code/event_code.py` 新增一段（P1 九步＋結果共 10 碼，編號接現有尾段）；`core/scheduler_update.py` 每分鐘那條在 `_report_tick` 旁加 `log_steps_once`。既有 `DBLogHandler` 自動補 user（system）與時間，不改 handler',
  edit=['app/product_update/service/upgrade_order.py', 'common/code/event_code.py', 'core/scheduler_update.py', 'test/test_fr167_upgrade_order.py'], new=[],
  deps=[], why="PM 2026-10-31 會議：「停機之後的可能要記錄下來，恢復時要回寫到我們的 system_log」。現況升級 log 在主機 `/srv/guidant-ai/log/upgrade-*.log`，容器停著寫不進 DB；稽核查「誰在何時升級、哪步花多久」要登主機。既有 event_code→system_logs 機制（memory reference_audit_event_logging_pattern）直接可用",
  accept='pytest：①進度檔 8 步＋結果 → 9 筆 log（caplog 斷言 event_code 與訊息含步名）；②同 job_id 第二次呼叫 0 筆；③進度檔 job_id 與紀錄列對不上 → 0 筆；突變：拿掉旗標寫入 → ②紅。DEV：手放一份 190 的進度檔到 DEV upgrade dir、觸發排程一次，`SELECT * FROM system_logs WHERE event_code BETWEEN <新段>` 看到 9 筆。event_code 表是登記型檔，本批只有本卡動它'),
dict(id="P11-5", phase="P11", stage=29, repo="main", model="sonnet", effort="medium",
  title='診斷包收進升級與安裝紀錄：log/upgrade-*.log、log/install-*.log 最近三支、last-switch.json',
  ren='extend：`scripts/installer/lib/diag_upgrade_stage.sh` `diag_stage_upgrade_files` 多收 `${GUIDANT_DATA_DIR}/log/upgrade-*.log`、`install-*.log`（`ls -t | head -3`）與 `${updir}/last-switch.json`，各加 `_diag_index_item`（kind：upgrade_log／install_log／last_switch）；容器內 `infra/support/staged_collector.py` `collect_upgrade_files` 對新檔走既有遮罩（log 內可能有 `GUIDANT_BUGSINK_ADMIN_PASSWORD=` 這類行，確認遮罩規則涵蓋）。手冊 support 章「診斷包內容」表加三列',
  edit=['scripts/installer/lib/diag_upgrade_stage.sh', 'infra/support/staged_collector.py', 'test/test_support_diag_bundle.py', 'docs/user-manual/onprem/04-operations.md'], new=[],
  deps=[], why="PM 2026-10-31 會議：「安裝更新包那些有沒有放到診斷包，看起來是沒有」。現況只收 upgrade-progress.json 與更新單，升級 log 與 install transcript 都在主機 log/ 沒收，原廠拿到診斷包還是要登主機",
  accept='本機 bats／pytest：staging 目錄有三支 upgrade log 時只收最近 3 支且 index 有三筆；沒有 log 時 index 寫 unavailable 不報錯；log 內塞一行 `PASSWORD=abc` 打包後是 `***`；DEV 8003 實匯一次診斷包開 tar 看到新檔與 manifest 列'),
dict(id="P11-6", phase="P11", stage=30, repo="main", model="opus", effort="medium",
  title='主系統把 Agent 升級結果回報更新站（target=agent）',
  ren='extend：`app/product_update/service/update_site_client.py` `report()` 多參數 `target="host"`、`agent_fingerprint=None`，body 照 P11-1 回寫的欄位名；`agent_rollout_service.record_upgrade_result` 收到每台終態（succeeded／failed）後呼叫一次（背景、失敗只 warning，比照主機 report）；「已報過」用 rollout 列加欄位 `reported_at`（migration `agent_update_rollouts` ADD COLUMN）而不是 root config（多台）。`_summary`／overview 不變',
  edit=['app/product_update/service/update_site_client.py', 'app/product_update/service/agent_rollout_service.py', 'infra/product_update/agent_update_rollout_model.py', 'test/test_fr167_agent_rollout.py'], new=['scripts/sql/2026-11-01-fr167-agent-rollout-reported-at.sql'],
  deps=["P11-1"], why="P11-1 更新站半做完後主系統接上，依賴它回寫的 body 欄位名，所以放下一批。不動 `upgrade_order.py`（P11-4 在改）",
  accept='pytest：Agent 回報 succeeded → report 打一次 body 含 target=agent、agent_fingerprint、from／to；重送同結果不再打（reported_at 有值）；更新站 5xx 只 warning；突變拿掉 reported_at 判斷 → 重送 case 紅。本機 `wrangler dev`（套 P11-1 的 0004）實打一次 204。migration 只套 DEV，回報「出貨基線待重產」'),
dict(id="P11-7", phase="P11", stage=31, repo="main", model="opus", effort="high",
  title='結案：本機驗 → 版號 1.27.9 → 188 出包；update-center 部署 0004＋新 Worker 由首腦做',
  ren='reuse：P10-8 的出包流程（`/home/jedi/fr167-build-1278.sh`，腳本不帶版號只需 188 pull 後重跑；三道前提已在腳本）、開包驗法。小弟做到 188 出包＋開包；update-center `wrangler deploy`＋`d1 migrations apply`、簽發、三台升級由首腦做',
  edit=['pyproject.toml'], new=[],
  deps=["P11-1", "P11-2", "P11-3", "P11-4", "P11-5", "P11-6"], why="批 29～30 六張合回後出 1.27.9；FE 版號由首腦對齊",
  accept='188 `.build/bundle/` 有 1.27.9 包＋sha256＋release.json＋ops.tar；開包：storage-seed 11 檔、`diag_upgrade_stage.sh` 含 upgrade_log 字樣、BE image 內 `event_code` 新段存在（grep 新事件碼名）；本機新裝＋升級（從 1.27.8 dump）0 錯誤；`audit_deps.sh` 0 項；回寫包路徑與 sha256'),
# ───────── P12 收口：1.27.10 上線後決策者看到的缺口（批 32～33） ─────────
dict(id="P12-1", phase="P12", stage=32, repo="agent", model="opus", effort="medium",
  title='Agent 主機升級：解包前查磁碟空間、docker 失敗把真正原因帶進失敗訊息、升級成功後清舊映像',
  ren='extend：`scripts/install/lib/upgrade_order.sh` `_uo_precheck` 加一條磁碟檢查（在「不降版」之後、flock 之前）；`_uo_load_and_verify` 第 305 行 `docker create … 2>/dev/null` 改成把 stderr 收進變數、失敗訊息帶最後一行（`docker load` 第 300 行同樣處理）；`scripts/install/lib/upgrade_runner.sh` `_upr_cleanup` 在 compose 形態補「刪掉 guidant-ai-agent 不是現行也不是 prev_images 的 tag」。三處都是在既有函式內加一段，不新開檔',
  edit=['scripts/install/lib/upgrade_order.sh（_uo_precheck 約 215 行、_uo_load_and_verify 296-310 行）', 'scripts/install/lib/upgrade_runner.sh（_upr_cleanup 281 行起）', 'test/shell/upgrade_order.bats（26 case）', 'test/shell/upgrade_runner.bats（18 case）'], new=[],
  deps=[], why="2026-11-01 189 推 Agent 1.4.8：60.167 失敗「無法讀取新映像」，畫面與進度檔只有這七個字；首腦翻 dockerd journal 才看到 `failed to extract layer … no space left on device`——升級途中要同時放下載包 1.7G、解開 1.7G、載入映像 4.3G、解快照 4.3G，該機剩 13G 不夠；121 剩 99G 所以過。runner 的 `_upr_preflight` 有查空間但只量 `bundle/agent` 一個目錄且跑在 extract 之後，擋不到這次。另外 188／190 各留 5 顆 4.2G 舊 Agent 映像（STATE 待辦 43），就是因為 cleanup 不清",
  accept='bats 新 case：①`_uo_precheck` 在 `df` 回可用 < 需求時回 `failed:磁碟空間不足：可用 XMB，這次升級約需 YMB（下載包＋解開＋載入映像），請先清理 docker 舊映像或 /var/lib/docker`（需求＝包大小 × 4 ＋ 1GB，用 stub 的 df 驗）；②`docker create` stub 回非零並印 `no space left on device` 時，失敗訊息是 `無法讀取新映像：no space left on device`（不是只有前七字）；③`_upr_cleanup` 對 stub 的 `docker images` 清單（現行 1.4.8、prev 1.4.7、多出 1.4.5／1.4.6）只 `rmi` 1.4.5／1.4.6，現行與 prev 不碰；突變：把 rmi 的排除條件拿掉 → case ③ 紅。shellcheck 綠。本機 docker `ubuntu:24.04` 容器跑 bats（mac 上有假紅）'),
dict(id="P12-2", phase="P12", stage=32, repo="agent", model="sonnet", effort="low",
  title='SeaweedFS 容器加 pids 上限與記憶體上限（1.4.7 跑一天 PID 已 5,600）',
  ren='extend：`deploy/docker-compose.yml` `guidant-agent-seaweedfs` 服務段比照 agent 服務（第 158-161 行 `security_opt`／`mem_limit`／`pids_limit`）加 `pids_limit: ${SEAWEEDFS_PIDS_LIMIT:-1024}`、`mem_limit: ${SEAWEEDFS_MEM_LIMIT:-2g}`；`.env` 範本補兩個變數說明。不改 SeaweedFS 版本、不查洩漏根因（另記）',
  edit=['deploy/docker-compose.yml（guidant-agent-seaweedfs 段 249 行起）', 'deploy/.env.example 或等價範本'], new=[],
  deps=[], why="STATE 待辦 53：1.4.2 跑三週 seaweedfs PID 15,360／18,478、`can't fork`、健康檢查 unhealthy。首腦 2026-11-01 13:3x 量 121 與 60.167（1.4.7，跑 24 小時）各 5,665／5,594，agent 本體 12——洩漏還在，不設上限幾天內又會倒。上限讓它在 1,024 時被 OOM／fork 拒絕而重啟（restart unless-stopped），比整台 Agent 掛掉好",
  accept='`docker compose -f deploy/docker-compose.yml config` 看到兩個新 limit；`build_compose_bundle.sh` 第 165 行 healthcheck 計數仍是 2；本機 `docker compose up seaweedfs` 後 `docker inspect` 的 `HostConfig.PidsLimit`＝1024。寫進 RELEASE_NOTES.md 下一版段一條白話'),
dict(id="P12-3", phase="P12", stage=32, repo="fe", model="sonnet", effort="low",
  title='Agent 區 8 格進度讀對欄位（永遠停第 1 格）、沒東西可展開時不畫箭頭',
  ren='extend：`src/views/product-update/agentRolloutState.js` 第 108 行 `agent.rollout?.progress` 改 `agent.progress`（BE overview 放在每台 `item["progress"]`，見 BE `app/product_update/service/agent_rollout_service.py:633`；`rollout` 物件裡從來沒有 progress）；`AgentRolloutPanel.vue` 第 185 行 `<Column expander>` 改用 `#body` slot：`stepRowsOf(data).length ? 原 expander 按鈕 : 空`（PrimeVue 3 DataTable 的 expander 欄用 `#rowtogglericon` 或自畫按鈕呼叫 `toggleRow`，看既有版本支援哪個）',
  edit=['src/views/product-update/agentRolloutState.js:108', 'src/views/product-update/AgentRolloutPanel.vue:185', 'src/__tests__/agent-rollout-state.spec.js', 'src/views/product-update/__tests__/AgentRolloutPanel.spec.js'], new=[],
  deps=[], why="STATE 待辦 60：決策者 2026-11-01 12:28 在 189 推 1.4.8 時看到 8 格一直停在「等 Agent 接手」。BE P9-6 與 FE P9-10 同批平行做、欄位位置從一開始沒對上；成功時 FE 直接全打勾不看進度所以 10-31 沒發現。待辦 41：expander 整欄固定畫箭頭，沒紀錄的台也有箭頭、點了空白",
  accept='vitest：`agentStepRows({rollout:{status:"dispatched"}, progress:{phase:"download", …}})` 第 2 格 now（突變：改回讀 rollout.progress → 紅）；Panel spec：rows 空的台 expander 欄不渲染按鈕、有 rows 的台有。手測：本機 vite 接 DEV 8000，用 mock 的 overview（把 `progress` 放在 agent 層）看 8 格會走；`grep -rn border-left` 0；`vite build` 0'),
dict(id="P12-4", phase="P12", stage=32, repo="main", model="opus", effort="medium",
  title='Bugsink 管理員密碼改每台安裝時隨機產生，不再出包時烙同一組',
  ren='extend：`scripts/installer/bugsink.sh` 第 227-234 行「讀包內 bugsink-admin.secret」改成：`${GUIDANT_DATA_DIR}/bugsink-admin.secret` 已存在就用它（升級路徑，既有客戶不變），否則 `openssl rand -base64 24 | tr -d "/+="` 產 24 字元寫入（首次安裝）；包內那個檔不再是必要條件（有就當 fallback 給舊包，沒有不 warn）。`scripts/build/build_bundle.sh:426` 的 `gen_bugsink_admin_bake.sh` 呼叫改成可選（變數沒設就跳過、不 fail），`build_all.sh:179` 的前提檢查同步拿掉這一項。`guidantai_bugsink.sh:60` 讀本機檔不變',
  edit=['scripts/installer/bugsink.sh:225-262', 'scripts/build/build_bundle.sh:426', 'scripts/build/build_all.sh:50,179', 'scripts/build/gen_bugsink_admin_bake.sh（註解改成「可選」）', 'docs/user-manual 錯誤追蹤站段（密碼在主機 bugsink-admin.secret，root 讀）'], new=[],
  deps=[], why="STATE 待辦 39：FR-133 D5 當初取捨為「CI 遮罩變數烙進包」，結果每台客戶同一組密碼，任何一台外洩等於全部外洩；決策者 10-31 問起。改成裝機時產生，密碼只存在那台主機 root 讀得到的檔；原廠要查走既有 `guidantai bugsink` 子命令（不印明文的那條，CLAUDE.md memory feedback_no_cli_that_prints_secrets_to_customer）",
  accept='bats：①資料目錄已有 secret → 用既有值、不重寫（升級不換密碼，突變：拿掉 exists 判斷 → 紅）；②沒有 secret 也沒包內檔 → 產 24 字元、檔 600 root、不含 `$`（compose 插值陷阱）；③`build_bundle.sh` 在變數未設時不 fail、包內無 secret 檔。shellcheck 綠。手冊段改好。本機 docker 容器跑 bats'),
dict(id="P12-5", phase="P12", stage=33, repo="main", model="opus", effort="high",
  title='結案：本機驗新裝＋升級 → 1.27.11／Agent 1.4.9 → 188 出包；發布與三台升級由首腦做',
  ren='照 P11-7（CM-3372）流程：合回確認、`pyproject.toml` 1.27.11、本機驗、188 跑 `/home/jedi/fr167-build-12710.sh`（Agent 1.4.9 一起出，主控台前端產物由首腦先 scp）。開包 grep：Agent compose 的 `docker-compose.yml` 有 `pids_limit` 兩處、`upgrade_order.sh` 有「磁碟空間不足」字串、FE image 字串 `agent.progress`',
  edit=['pyproject.toml'], new=[],
  deps=["P12-1", "P12-2", "P12-3"], why="三張合回後一次出包（P12-4 Bugsink 密碼決策者 11-01 裁先不做，卡 CM-3377 已關）；版號 1.27.11 與 Agent 1.4.9 讓更新站與 189 都能推（同版 412 擋）。188 磁碟 99%（6.7G）出不了包，首腦在派這張前先清（build cache 74G 可回收 34G）",
  accept='本機新裝＋從 1.27.10 升級都 0 錯誤（照 CM-3344／P10-8 驗法）；188 出包 EXIT=0、開包三項 grep 全中。回寫包路徑、sha256、磁碟用量'),
]

PHASE_NAME = {"P1":"升級變安全","P2":"更新站","P3":"更新中心","P4":"Agent 跟進","P5":"收尾","P6":"開發期收完後的修正","P7":"雛形 v2 定案後的接線","P8":"190 測試期修正","P9":"測試期第二輪：當次可驗、秒級第 0 步、快速出包","P10":"188／189 升 1.27.7 後的修正：空窗、側欄、翻譯、CRA PDF、90 天、report、Agent 判定、繁中舊列","P11":"PM 會議（2026-10-31）功能調整 A 類：Agent 結果回報更新站、文案、支援頁精簡、升級紀錄回寫 system_log、診斷包收升級 log","P12":"1.27.10 上線後的收口：Agent 升級磁碟預檢與錯誤原因、SeaweedFS 上限、8 格欄位、Bugsink 密碼每台隨機、結案 1.27.11"}
REPO_NAME = {"main":"BE","fe":"FE","jedi-packages":"jedi 套件","agent":"evidence-agent","license-center":"License Center","update-center":"update-center（新）","test":"test"}

if __name__ == "__main__":
    import collections
    by = collections.defaultdict(list)
    for c in CARDS: by[c["stage"]].append(c)
    ids = [c["id"] for c in CARDS]
    assert len(ids) == len(set(ids)), "duplicate id"
    for c in CARDS:
        for d in c["deps"]:
            assert d in ids, (c["id"], d)
            dep = next(x for x in CARDS if x["id"]==d)
            assert dep["stage"] < c["stage"], f"{c['id']} (stage {c['stage']}) depends on {d} (stage {dep['stage']})"
    clashes = 0
    for st, cs in sorted(by.items()):
        seen = {}
        for c in cs:
            for f in c["edit"]:
                key = (c["repo"], f.split(" ")[0].split("（")[0])
                if key in seen: print(f"⚠ 批 {st}: {c['id']} 與 {seen[key]} 都改 {key}"); clashes += 1
                seen[key] = c["id"]
    assert clashes == 0, f"{clashes} 組同批撞檔"
    print("cards", len(CARDS), "stages", len(by))
    for st, cs in sorted(by.items()): print(f"批 {st}: " + ", ".join(f"{c['id']}({c['repo']})" for c in cs))
    rc = collections.Counter(c["repo"] for c in CARDS); print("repo:", dict(rc))
