"""FR-173 拆卡底表：出版管線定稿。design.md §6、Notion spec、工作坊四欄都從這張表產。

repo 值：main（BE）、fe、agent、jedi-packages。update-center 與 License Center 不進管線（決策者裁），本案不碰。

本案前提（決策者 2026-11-01 裁）：
- **排在 171、172 之後做**，開發不受影響；1.28.0 若來不及就照舊 Mac 手簽出，173 跟下一版。
- **決策者功課（開發期間自己做，卡開工前要回報完成）**：
  ① 自架 HashiCorp Vault（獨立小 VM，不放 188）、啟用 Transit 引擎、建 Ed25519 鑰 `guidant-release`（exportable=false）、開 audit log；
  ② Vault 設 GitLab JWT/OIDC auth，角色綁 BE／Agent 專案的**保護 tag** pipeline，policy 只給 `transit/sign/guidant-release`；
  ③ 建置期祕密（Nexus npm 認證、R2 金鑰、更新站 CI token、Harbor 帳密）搬進 Vault KV，188 上 `~/.npmrc` 與 `.env` 裡那幾個刪掉；
  ④ runner：BE、Agent、FE 都用 188 shell runner（`guidantai-build`，決策者 2026-11-03 裁）；jedi 套件群組的 runner 待決策者開。
  Vault 位址、角色名、鑰匙名由決策者回報後填進卡（卡內先寫占位 `<VAULT_ADDR>`）。
- **出包時就蓋章、GitLab 身分發布**（決策者 2026-11-03 裁）：CI 打包時向 Vault 要簽名、登記「蓋好章的草稿」只給內部機器看；試完在 GitLab 按 publish job 公開，發布不再蓋章。原因：客戶端拒收未簽草稿，舊流程「先裝再蓋章」走不通；更新站 publish 原本只收決策者本人登入。
- **測試包模式**（同日裁）：擴充 CI 既有 `internal_test`，跳排練、SBOM／弱掃、Harbor、R2、更新站，用 Vault 測試章 `guidant-test` 簽、scp 直升 190。
- Mac 根印章保留當備用 kid。Vault 那把的公鑰（kid `05086455d42ca0d6`）已隨 1.28.0 放進 BE `scripts/installer/release-keys/` 與 `PROD_KIDS`（FR-172 S-4），本案不用再加。
- 不改演算法（Ed25519 照舊），客戶端驗章零改動。

登記型檔只有 owner 能碰：
- BE `.gitlab-ci.yml`：owner C-1
- Agent `.gitlab-ci.yml`：owner C-2（新檔）
- FE `.gitlab-ci*.yml`：owner C-3
- update-center `cli/`、`src/`：owner V-1

與 172 共用 branch `feature/FR-172`（決策者 2026-11-02 裁）。
"""

CARDS = [
dict(id="V-1", phase="V", stage=1, repo="update-center", model="opus", effort="high",
  title="出包時就蓋章、GitLab 身分發布：蓋章 CLI 接 Vault Transit（正式章 guidant-release、測試章 guidant-test），新增 sign-draft 子命令讓 CI 登記草稿時帶上簽好的清單（Agent 版同時產離線檔）；更新站 Worker 收簽好的草稿、發布端點多認 GitLab 的 job 身分（只限 BE／Agent 專案受保護 tag），發布不再帶章；Mac 私鑰留備用",
  ren="extend：`cli/keystore.ts` 加 `VaultSigner`（與既有檔案私鑰 signer 同介面）；`cli/envelope.ts` 簽章函式吃 signer；`cli/commands.ts` 加 `sign-draft`（`--product agent` 時 reuse `cli/offline.ts` 產離線檔）、`publish --signer vault|file`；Worker `src/access.ts` 照 `verifyAccessJwt` 的寫法加 GitLab ID token 驗證、`register-draft` 收 `signed_manifest`＋`kid`、`publish` 對 GitLab 身分只改狀態不收章",
  edit=["cli/keystore.ts", "cli/envelope.ts", "cli/commands.ts", "cli/README.md", "src/access.ts", "src/routes/admin.ts", "src/releases.ts", "test/"],
  new=["cli/vault_signer.ts", "test/cli/vault_signer.spec.ts", "test/gitlab_identity.spec.ts"],
  deps=[], why="蓋章搬到 Vault；同時修掉「內部先裝要先裝未簽草稿、客戶端拒收」與「CI 送不回章」兩個缺口",
  accept="vitest：`VaultSigner` 對 mock 的 Vault 組出合法簽名、`verifyRelease` 驗過；file signer 行為不變；缺 Vault 身分明確錯誤不退回 file；register-draft 帶簽好清單後內部先裝機器 check 拿得到 `signed_manifest`；GitLab ID token 不是 BE／Agent 專案、不是受保護 ref、過期、aud 不對都 403；Cloudflare 本人登入的 publish 路徑照舊可用。用開發帳號對 172 以測試章實簽一份清單並驗過；正式章只讀公鑰、核 kid＝`05086455d42ca0d6`（開發帳號不能簽正式章，正式章實簽在 C-1 由 CI 驗）"),

dict(id="C-3", phase="C", stage=1, repo="fe", model="opus", effort="medium",
  title="FE 自己的 CI：刪三支舊檔（ECR／ECS／ansible），換一支 verify＋build：push 跑 lint、vitest、npm 弱掃（syft＋grype 對 package-lock）；BE 觸發或 tag 時 build FE image 推 Harbor；主控台前端同一支 pipeline build 成 artifact 給 Agent CI 拿",
  ren="new（照 BE CI 分段，跑 188 的 `guidantai-build` shell runner；188 沒裝 node，npm 一律在 node 容器裡跑）：弱掃 reuse BE `scripts/build/sbom.sh` 的 lockfile 掃法搬成 FE repo 自己的 `scripts/ci/sbom_fe.sh`；Nexus 認證從 Vault KV 拿（job 內用 `id_tokens` 登入 Vault 再讀 KV；GitLab 免費版不能用 `secrets:` 關鍵字），不再 GitLab Variable",
  edit=[".gitlab-ci.yml（重寫）"], new=["scripts/ci/sbom_fe.sh", "scripts/ci/vuln-allowlist.txt"],
  deps=[], why="FE 三支 CI 是 SaaS 時代死檔；push 時沒有任何掃描",
  accept="刪 `.gitlab-ci-on-premises.yml`、`.gitlab-ci-terraform.yml`；push feature branch 跑 verify 綠（有白名單外 Critical 會紅）；tag 跑 build 推 Harbor `guidant-ai-fe:<版>`；artifacts 有 `dist-console/`"),
dict(id="C-3b", phase="C", stage=2, repo="fe", model="opus", effort="medium",
  title="FE Dockerfile 多一個主控台打包目標 console-dist：docker build 就能產出 Agent 主控台 dist-console/，不必在 Mac 打包（從 C-2 拆出，CM-3480）",
  ren="extend：既有 Dockerfile 拆出共用 deps stage，主系統與主控台各一個 build stage；不帶 --target 時產物不變",
  edit=["Dockerfile"], new=[],
  deps=[], why="免費版不能跨專案下載 artifact；改成 Agent CI 用同一份 Dockerfile 自己打包主控台",
  accept="`docker build --target console-dist --output type=local` 產出 index.html＋assets/（≤2 MB）；主系統映像照舊通過 build_image.sh --no-push 檢查"),
dict(id="C-2", phase="C", stage=2, repo="agent", model="opus", effort="high",
  title="Agent 自己的 CI（新）：verify → build（主控台前端用 FE Dockerfile console-dist 在 188 打包）→ rehearse → package（SBOM 放行門、build_compose_bundle、Harbor、sign-draft 蓋 Vault 章並產離線檔、登記草稿 product=agent、R2 agent/v1/）→ 手動 publish；License Center 送簽 token 改從 Vault 拿；測試包模式同 C-1；188 shell runner，Nuitka 段與 BE 共用 188 主機鎖",
  ren="new：照 BE `.gitlab-ci.yml` 五段與 `.sync_repo`／`.in_repo`／`.collect` 片段；`publish_draft.sh` 從 BE 複製成 Agent 版加 `--product agent`（先複製，不跨 repo 共用）；`vault_login.sh` 照 C-1 那支複製；測試包模式沿用既有 `--internal-test`",
  edit=["scripts/build/build_all.sh（CI 模式與既有 --internal-test 併用）", "scripts/build/README.md"], new=[".gitlab-ci.yml", "scripts/build/publish_draft.sh", "scripts/build/vault_login.sh"],
  deps=["C-3", "C-3b"], why="Agent 出包從人手跑變 pipeline；主控台前端在 188 用 FE Dockerfile 的 console-dist 目標自己打包",
  accept="正式模式：五段綠、更新站草稿 `product=agent` 帶 Vault 章、R2 `agent/v1/<版>/` 三檔、188 `.build/compose/` 有離線檔、按 publish 後 published；188 `/opt/guidant-ai-agent/.env` 不再需要 `LICENSE_CENTER_API_TOKEN`；測試包模式：測試章簽、不碰 Harbor／R2／更新站；與 BE pipeline 同時觸發時第二條在 `resource_group` 排隊不併跑"),

dict(id="C-1", phase="C", stage=2, repo="main", model="opus", effort="high",
  title="BE 發版 pipeline 改版：trigger FE 下游（strategy mirror）、不再在 188 build FE、祕密從 Vault 拿、package 段用 V-1 的 sign-draft 向 Vault 蓋章後登記草稿、最後一個手動 job「發布」用 GitLab 身分叫更新站公開；internal_test 擴成「測試包」模式（跳排練、SBOM／弱掃、Harbor、R2、更新站，測試章簽、scp 直升 190）；release note 檔尾自動送更新站；runbook 改寫",
  ren="extend：`.gitlab-ci.yml` 的 `build-fe` 改 `trigger:`；`package` 段加 sign-draft、新 job `publish`（manual、只在受保護 tag）；`internal_test` input 改成測試包模式（既有開關擴充，不另開）；`publish_draft.sh` 帶簽好清單與 release note 摘要；runbook 步驟 2.2／2.3／4 重寫",
  edit=[".gitlab-ci.yml", "scripts/build/publish_draft.sh（帶簽好清單＋release note 摘要）", "docs/features/FR-167-2610-online-update-channel/release-runbook.md"],
  new=["scripts/build/release_notes_tail.sh", "scripts/build/vault_login.sh"],
  deps=["V-1", "C-3"], why="總指揮；FR-167 裁示「私鑰只在 Mac」由此正式改為「Vault 簽、Mac 備用」",
  accept="正式模式（受保護 tag）：FE 下游綠才進 build-be；草稿登記時已帶 Vault 章、只有 190 check 看得到；190 升上去後按 `publish` job，更新站轉 published、kid 是 Vault 正式章；從 branch 跑的 pipeline 要不到正式章、`publish` job 不出現。測試包模式：不跑排練／SBOM／Harbor／R2／更新站，包用測試章簽、scp 升 190 成功，總時間比正式模式少 20 分以上。188 上 `grep -r R2_SECRET ~/.npmrc /opt/*/.env` 為 0；runbook 無「Mac 蓋章」步驟（備用章段除外）"),

dict(id="C-4", phase="C", stage=3, repo="jedi-packages", model="sonnet", effort="medium",
  title="jedi 套件 CI：push 跑各套件 pytest 與 pip-audit；分類器 image 由 BE 下游 trigger build 推 Harbor（runner 待決策者開給套件群組）",
  ren="extend：既有 `.gitlab-ci.yml`（若有）加 job；分類器 image build reuse BE `build_classifier_image.sh` 搬到套件 repo",
  edit=[".gitlab-ci.yml"], new=["jedi-evidence-classification/docker/ci.sh"],
  deps=["C-1"], why="BE 不再在 188 build 分類器；jedi 改動 push 就測",
  accept="push 跑測試綠；BE tag 觸發時分類器 image 在 Harbor 有新 tag"),
dict(id="D-1", phase="D", stage=3, repo="main", model="sonnet", effort="low",
  title="文件：補齊獨立文件 docs/system-design/release-pipeline/出版管線總覽.md 八段（全景、機器、服務、祕密、連線、流程、從零重建、出事怎麼辦），含 Vault 鑰匙輪換與備用章啟用 SOP；FR-173 的 FINAL-SPEC 只放指向它的摘要",
  ren="new 文件", edit=["docs/system-design/release-pipeline/出版管線總覽.md", "docs/system-design/README.md"], new=["docs/features/FR-173-2611-release-pipeline/FINAL-SPEC.md"],
  deps=["C-1", "C-2"], why="收口",
  accept="總覽八段沒有「待填」；每台機器、每個服務都實查過（ssh／API 讀回），不照抄 design；FINAL-SPEC 五段齊；`grep -n \"原本\\|改判\\|第 [0-9] 棒\"` 為 0"),
]

PHASE_NAME = {"V":"簽章金庫", "C":"三條 CI", "D":"文件"}
REPO_NAME = {"main":"BE", "fe":"FE", "agent":"evidence-agent", "jedi-packages":"jedi 套件", "update-center":"update-center"}

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)
            # C-2 開工後才拆出 C-3b；工作坊規定不改既有卡批次，C-2 已在跑，只能同批、由 C-2 等 C-3b 合回
            if (c["id"], d) == ("C-2", "C-3b"): continue
            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))
