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

每張卡：id／階段／批／repo／標題／reuse-extend-new／改哪些既有檔／新開哪些檔／依賴／驗收／model／effort／理由。
批（Stage）＝能同時跑的那一組：不改同一既有檔、不依賴對方產出、不搶同一外部資源。階段只是分組標籤。
repo 值：main（BE）、fe、jedi-packages、agent（evidence-agent）。

登記型檔 owner（其他卡不碰）：
  jedi-agent-protocol `console_scopes.py`＋`__init__.py`      → P0-1
  jedi-remote-agent `plugin/contract.py`                      → P1-1
  BE `core/plugins/remote_agent.py` `_ADMIN_CAPABILITIES`      → P1-3
  BE `scripts/sql/manifest.tsv`                               → P1-2（本案唯一一支主線 migration）
  Agent `shared/constants.py`＋`sync/domain/agent_auth.py`     → P1-4
  Agent `console/api/*.py` 守門（約 60 條）                     → P1-5（一張卡全換，不拆：拆會撞 __init__.py）
  FE console `router.js`＋各 view                              → P1-6
  FE 主站 `permission-tree.json`／`remote-agent.json`／`capabilities.js` → P1-7
"""

CARDS = [
# ───────── P0 契約（批 1：環境卡＋契約卡；兩者不同 repo、不撞檔） ─────────
dict(id="P0-0", phase="P0", stage=1, repo="main", model="sonnet", effort="medium",
  title="環境卡：主系統與 Agent 開發期改走本機路徑裝 jedi-agent-protocol 與 jedi-remote-agent（不 commit）",
  ren="reuse：主系統 `pyproject.toml` 第 132 行已有 jedi-remote-agent 的 path 註解行，取消註解即可；jedi-agent-protocol 照同形狀新加一行；Agent 的 `pyproject.toml` 同理",
  edit=["pyproject.toml 75-76、132（取消註解／加 path 行；assume-unchanged）", "../evidence-agent/pyproject.toml 14（同上）"],
  new=["docs/features/FR-190-2611-agent-console-capabilities/handoff/ENV.md（怎麼還原）"],
  deps=[], why="機械性：改兩個檔的 pin 成 path、驗 import 路徑；收尾卡再還原",
  accept="`python -c 'import jedi_agent_protocol, jedi_remote_agent; print(jedi_agent_protocol.__file__, jedi_remote_agent.__file__)'` 兩個路徑都在 `~/Projects/Jedicogy/module/jedi-python-package/` 底下；Agent venv 同樣印出 protocol 在套件 repo；`git status` 兩個 pyproject 不出現（assume-unchanged）；ENV.md 寫清還原步驟"),
dict(id="P0-1", phase="P0", stage=1, repo="jedi-packages", model="opus", effort="high",
  title="jedi-agent-protocol 新增 console_scopes：26 顆清單（模組 agent、資源／動作／顯示名／離線可下放／mirrors）＋三邊鏡像測試骨架",
  ren="new（契約模組）：照 `event_codes.py`／`error_codes.py` 的「只有常數與 dataclass、零相依」形狀；三邊鏡像測試照 `tests/test_mirror.py` 既有 `_sources.py` 定位法加三條",
  edit=["jedi-agent-protocol/jedi_agent_protocol/__init__.py（export）", "jedi-agent-protocol/pyproject.toml（0.5.3 → 0.6.0，破壞性：舊 7 範圍名退場）", "jedi-agent-protocol/README.md（內容表加一列）"],
  new=["jedi-agent-protocol/jedi_agent_protocol/console_scopes.py", "jedi-agent-protocol/tests/test_console_scopes.py"],
  deps=[], why="這張表是整案真相來源，命名、旗標、mirrors 一次定對；三邊都 import 它",
  accept="`from jedi_agent_protocol.console_scopes import CONSOLE_SCOPES, scope_of, capability_of, OFFLINE_GRANTABLE, MIRRORS, CAPABILITY_MODULE` 可用；`CAPABILITY_MODULE=='agent'`（🔴 不是 detection：seed 有 `detection.*` 萬用字元會把 26 顆送給稽核主管與唯讀）；26 顆、11 個資源、動作只在 {read,update,execute,download}；`scope_of('agent.files.download')=='files.download'` 雙向可逆；OFFLINE_GRANTABLE 為 24 顆：只排除 `update.read`、`update.execute`（D6：重啟 `ops.execute` 可下放）；tests 綠且含突變（把一顆改成第五種動作要紅；兩顆同名要紅）"),

# ───────── P1 四邊接上（批 2 不互依的六張；批 3 兩張依賴批 2 同 repo 產出） ─────────
dict(id="P1-1", phase="P1", stage=2, repo="jedi-packages", model="opus", effort="high",
  title="jedi-remote-agent 三張表改從 protocol 推導：CAPABILITIES 26 顆、簽票範圍、離線名單範圍；舊 7 顆從宣告拿掉",
  ren="extend：`plugin/contract.py` 的 `CAPABILITIES`／`CONSOLE_SCOPE_CAPABILITIES`／`OFFLINE_OPERATOR_SCOPE_CAPABILITIES` 由 `console_scopes` 推導產生，不再手寫；`agent_ticket_service.CONSOLE_SCOPES`、`offline_operator_service._scopes_for` 讀推導後的表（呼叫端零改）",
  edit=["jedi-remote-agent/jedi_remote_agent/plugin/contract.py 29-63", "jedi-remote-agent/jedi_remote_agent/api/routes/remote_agent_route.py 157-160（doc 字串）", "jedi-remote-agent/tests/test_offline_operator_service.py", "jedi-remote-agent/tests/test_plugin_layout.py", "jedi-remote-agent/pyproject.toml（1.17.6 → 1.18.0；protocol 下限 >=0.6.0,<0.7）"],
  new=[], deps=["P0-1"], why="三張表是套件對主系統的契約，改成推導要保住既有呼叫端行為；測試要重寫對照表",
  accept="`len(CAPABILITIES)==26+4`（26 顆主控台＋4 顆管理用 create/read/update/delete 不動）；每顆 `license_module='remote-agent-manage'`、`home_route='remote-agent-console'`；`CONSOLE_SCOPE_CAPABILITIES` 26 列、`OFFLINE_OPERATOR_SCOPE_CAPABILITIES` 24 列（少 update 兩顆）；舊名 `detection.remote-agent.configure` 等 7 顆 grep 套件只剩 `legacy_name` 欄；tests 綠"),
dict(id="P1-2", phase="P1", stage=2, repo="main", model="opus", effort="high",
  title="主系統 migration：角色樹子頁 remote-agent-console、舊 7 顆授權展開到 26 顆（含 pattern 角色）、刪舊顆；守衛測試「seed 萬用字元不得命中 agent.*」",
  ren="extend：照 `2026-10-27-fr166-agent-standalone-capabilities.sql` 四段形狀（能力點由 `_capabilities.sql` 攤平進表，本支不 INSERT capabilities）；展開對照表照 design §3 D5；子頁 ui_routes 照 `2026-10-08-fr132-tree-work-faces.sql` 的 tree_page 寫法",
  edit=["scripts/sql/manifest.tsv", "test/test_legacy_capability_name_references.py（舊名清單來源自動跟）", "test/test_fr132_capability_home_route.py（新顆 home_route 指子頁）"],
  new=["scripts/sql/2026-11-10-fr190-agent-console-capabilities.sql", "test/test_fr190_seed_patterns_exclude_agent_module.py"],
  deps=["P0-1", "P0-0"], why="授權展開是資料遷移，少一列就有角色掉權限；pattern 角色與 role_capabilities 兩條路都要走；刪舊顆要清三張關聯表",
  accept="DEV 套後：`select name from capabilities where name like 'detection.remote-agent.%'` 只剩 create/read/update/delete 四顆；`select count(*) from capabilities where module='agent'` 為 26；原持 `configure` 的系統角色現持 6 組檢視＋設定共 14 顆＋`scan.read`；原持 `download-files` 的持 4 顆 download（`ops.download` 只給同時持 configure 者）；`role_capability_patterns` 同步；`ui_routes` 有 `remote-agent-console`（is_menu=false、tree_page=true、pid=remote-agent-manage）；`CAPABILITY_CATALOG VERSION` +1；`pytest test/test_legacy_capability_name_references.py test/test_fr132_capability_home_route.py test/test_module_boundaries.py test/test_fr190_seed_patterns_exclude_agent_module.py` 綠（新守衛掃 `scripts/init/04-seed-core.sql` 與 `_capability_grants.sql` 模板：非 `*.*.*` 的 pattern 不得命中任何 `agent.*`，突變把 `detection.*.*` 改 `*.*.read` 要紅）；回寫明講「出貨基線待重產」"),
dict(id="P1-3", phase="P1", stage=2, repo="main", model="sonnet", effort="medium",
  title="主系統端點守門換顆（D7 表）、矩陣產生器加 download 動作與「Agent 端守門」標記、重產 capabilities.js",
  ren="extend：`_ADMIN_CAPABILITIES` 逐列換名（表在 design §3 D7）；`gen_permission_matrix.guarded_capabilities` 加一條「protocol 清單裡的顆視為 Agent 端有守」；`_agent_keys.py` 換發端點改顆",
  edit=["core/plugins/remote_agent.py 192-247", "core/plugins/_agent_keys.py 156-162", "scripts/deliverables/gen_permission_matrix.py 134-150", "api/remote_agent/__init__.py（若有舊名）"],
  new=[], deps=["P0-1"], why="機械性換名＋一條判定；對照表已在 design，runner 照表填",
  accept="`grep -rn 'remote-agent\\.\\(configure\\|browse-files\\|run-local-scan\\|net-diag\\|download-files\\|restart\\|upgrade\\)' core/ api/ app/` 為 0；`RemoteAgentLocalRunReportRoute.get` 守 `('agent.scan.download','agent.netdiag.download')`（ANY，route 內再依紀錄種類細判）；`RemoteAgentConsoleTicketRoute.post` 守 26 顆 ANY；`RemoteAgentToolCredentialsRoute.*` 守 `tools.update`；`AgentCredentialsRenewRoute.post` 守 `keys.update`；`RemoteAgentRestartRoute.post` 守 `ops.execute`；`RemoteAgentProfilesCheckRoute.post` 守 `ops.update`；`python scripts/deliverables/gen_permission_matrix.py` 跑過、26 顆不在 orphans、FE `capabilities.js` 多 26 鍵（檔由 P1-7 commit）；BE 重啟後實打簽票端點 200／無顆帳號 403"),
dict(id="P1-4", phase="P1", stage=2, repo="agent", model="opus", effort="high",
  title="Agent 身分層：CONSOLE_SCOPES 改 import protocol、驗票不認得的範圍丟掉並記數（D2）、離線名單過濾同步、/api/me 回 dropped_scopes",
  ren="extend：`shared/constants.py` 的 `CONSOLE_SCOPES` 改 `from jedi_agent_protocol.console_scopes import ...`；`verify_console_ticket` 把 `TicketScopeUnknown` 路徑改成過濾＋回傳 `(claims, dropped)`；`ConsoleSession` 加 `dropped_scopes` 欄（不落地）；`local_admin_claims` 用新清單全拿",
  edit=["guidant_agent/shared/constants.py 9", "guidant_agent/sync/domain/agent_auth.py 314-337", "guidant_agent/identity/app/session.py 31-60", "guidant_agent/identity/app/local_auth.py 166", "guidant_agent/identity/app/offline_operators.py 79-85", "guidant_agent/console/api/pages.py 79-93（me 多一欄）", "guidant_agent/console/api/__init__.py 140-155（登入入口接 dropped）", "test/test_agent_identity.py", "test/test_console_local_auth_routes.py"],
  new=[], deps=["P0-1", "P0-0"], why="認證核心：丟掉不認得的範圍不能變成放寬；全部範圍都不認得時要拒；要突變測試",
  accept="票 scope=['files.read','bogus'] → 登入成功、session.scope=['files.read']、`/api/me` 回 `dropped_scopes=1`；票 scope 全不認得 → 403 `TICKET_SCOPE_EMPTY`（不是 200 空權限）；舊 7 個名字一律當不認得；local-admin `scope` 等於 26 顆；離線名單帶不認得的 scope 照樣過濾不報錯；`test_fr166_guards.py` 第 2 條「scope 對表」改讀 protocol；突變：把過濾改成放行 unknown 要紅"),
dict(id="P1-5", phase="P1", stage=3, repo="agent", model="opus", effort="high",
  title="Agent 主控台約 60 條路由守門一對一換成 26 顆（含 D3 派工紀錄歸掃描）、守衛測試對表全 blueprint",
  ren="extend：每條 `require_session(\"<舊>\")` 換 `require_session(\"<新>\")`，對照表在 design §5.3；`runs_routes._KIND_SCOPE`／`_TASK_SCOPE`／`_LIST_SCOPE` 收成「掃描紀錄不分來源歸 scan.read」；`log_routes.SCOPES` 改 `logs.read`；`ops_routes` 診斷包改 `ops.download`（單顆，不再疊兩個 decorator）；`test_fr166_guards.py` 的 `_OPS_SCOPES` 對表擴成全 blueprint 的 `_SCOPE_TABLE`",
  edit=["guidant_agent/console/api/files.py", "scan_routes.py 34", "netdiag_routes.py 40", "runs_routes.py 30-70、150-165", "log_routes.py 26、58-60", "ops_routes.py 46-273", "tools.py", "ldap_routes.py", "keys_routes.py", "license_routes.py", "update_routes.py", "setup_routes.py", "cloud_change_routes.py", "local_auth_routes.py", "test/test_fr166_guards.py 390-430", "test/test_console*.py（conftest 的 scope 字面）"],
  new=["test/test_fr190_scope_table.py（全 blueprint 單一 scope 逐支打）"],
  deps=["P1-4"], why="60 條逐支換、每條要判「看／改／執行／下載」四選一；對表測試要能抓放寬",
  accept="`grep -rn 'require_session(\"\\(configure\\|files\\|run-local-scan\\|net-diag\\|download-files\\|restart\\|update\\)\")' guidant_agent/console/` 為 0；`test_fr190_scope_table.py` 對 26 顆各建單一 scope session 逐支打全部 `/console/api/*` 路由，放行集合等於 design §5.3 表；派工掃描紀錄用 `scan.read` 的 session 看得到、`tools.update` 的看不到；診斷包單顆 `ops.download` 可下載；LDAP 寫入仍 `local_admin_only`；突變：任一條守門放寬要紅"),
dict(id="P1-6", phase="P1", stage=2, repo="fe", model="opus", effort="medium",
  title="主控台前端：router.js 每頁 scope 換新顆、各 view 的 canDownload／canConfigure 換顆、舊版 Agent 橫幅（D2）、i18n 權限名",
  ren="extend：`router.js` 的 `scope` 欄逐頁換（表在 design §5.4）；`isVisible`／`canOpen` 不動；各 view 的 `(me.scope||[]).includes('configure')` 換成對應顆；`Shell.vue` 讀 `me.dropped_scopes>0` 顯示一條 `.hint` 橫幅（reuse 既有 hint 樣式，不新元件）；`agent-console-auth.json` 的 `scope_*` 文案換 26 顆",
  edit=["console/router.js 16-52", "console/Shell.vue 140、168-179", "console/views/Files.vue 153", "Logs.vue 103", "Runs.vue 185-331", "Scan.vue 159", "NetDiag.vue 21", "Ops.vue 120＋ops.js 49-54", "LdapSettings.vue 136", "License.vue 97", "Keys.vue 117", "Update.vue 122", "Tools.vue", "Setup.vue", "console/runs.js 82", "src/config/locales/i18n/{zh-tw,en}/agent-console-auth.json", "src/__tests__/agent-console-*.spec.js（19 支的 scope 字面）"],
  new=[], deps=["P0-1"], why="十幾個 view 各自判權限，要逐一對表；橫幅文案進 i18n；測試 19 支要跟",
  accept="`npm run build:CONSOLE` 過；`grep -rn \"'configure'\\|'files'\\|'run-local-scan'\\|'net-diag'\\|'download-files'\\|'restart'\\|'update'\" console/ | grep -v i18n` 為 0；用只有 `files.read`＋`scan.read`＋`scan.download` 的 me 跑 vitest：側欄只出現總覽／掃描／執行紀錄／檔案／授權，檔案頁無下載鈕，執行紀錄有下載鈕；`me.dropped_scopes=3` 時頂部出現橫幅；`grep border-left console/` 為 0；**雛形只取結構，文案全部依 `agent-console*.json` 既有語氣重寫**"),
dict(id="P1-7", phase="P1", stage=3, repo="fe", model="sonnet", effort="medium",
  title="主站：角色樹 26 顆標籤與說明、動作排序加 download、離線操作者標籤、Agent 管理頁按鈕換顆、commit 重產的 capabilities.js",
  ren="extend：`permission-tree.json` 的 `detection.remote-agent` 底下 7 顆換成 11 個 `detection.agent-*` 資源各 2～4 顆（兩份：標籤與說明）；`usePermissionGrouping.ACTION_ORDER` 加 `download`；`remote-agent.json` 的 `scope_*` 五條換 26 條；`OfflineOperatorsDialog.vue` 的 `scopeLabels` 對照改讀新 key；Agent 管理頁各按鈕 `v-can` 換顆（表在 design §5.5）",
  edit=["src/config/locales/i18n/{zh-tw,en}/permission-tree.json 444-456、947-958", "src/composables/usePermissionGrouping.js 56", "src/config/locales/i18n/{zh-tw,en}/remote-agent.json 127-132", "src/views/remote-agent/OfflineOperatorsDialog.vue 45-50", "src/views/remote-agent/RemoteAgentManage.vue（按鈕 v-can；766 行超標：只改 v-can 字串）", "src/views/remote-agent/AgentLocalRunsView.vue 213", "src/config/capabilities.js（P1-3 重產，本卡 commit）", "src/composables/__tests__/usePermissionGrouping.spec.js"],
  new=[], deps=["P1-3"], why="文案與對照為主；RemoteAgentManage.vue 超標只准改字串",
  accept="`npm run build` 過；角色頁「檢測 › Agent 管理」底下出現「Agent 主控台」子頁、26 顆依資源分組、動作順序 read→update→execute→download；舊 7 顆標籤 key 刪除；離線操作者對話框 tag 顯示「檔案 · 檢視」等新名；`npm run test -- usePermissionGrouping` 綠；`grep -rn 'remote-agent\\.\\(configure\\|browse-files\\|run-local-scan\\|net-diag\\|download-files\\|restart\\|upgrade\\)' src/` 為 0；**雛形只取結構，文案全部依 `permission-tree.json` 既有語氣重寫**"),

# ───────── P2 帶入鈕（批 4 端點 → 批 5 FE 鈕） ─────────
dict(id="P2-1", phase="P2", stage=4, repo="main", model="sonnet", effort="medium",
  title="主系統端點：角色「比照主系統功能帶入」—— 依 protocol MIRRORS 算該角色應加的 Agent 顆（只算不寫，FE 勾起來由既有儲存走）",
  ren="extend：在 jedi-iam 既有角色 app service 旁加一支唯讀端點 `GET /roles/<uid>/agent-console-mirror`，回 `{add:[cap names], already:[...]}`；MIRRORS 來自 protocol，不在 BE 另寫對照；守門 `iam.role.update`",
  edit=["api/（角色模組的 route 登記檔，開卡前 `ls api/ | grep role` 定位）"],
  new=["app/access/service/agent_console_mirror_service.py", "api/access/routes/agent_console_mirror_route.py"],
  deps=["P1-2", "P1-3"], why="一支唯讀端點＋一段集合運算；對照表現成",
  accept="對持有 `detection.plugin.update` 的角色呼叫 → `add` 含 `agent.tools.update`；對已持有者回在 `already`；無 `iam.role.update` 403；BE 重啟後實打；守衛測試綠"),
dict(id="P2-2", phase="P2", stage=5, repo="fe", model="sonnet", effort="medium",
  title="主站角色頁：「Agent 主控台」子頁標題列加「比照主系統功能帶入」鈕，按了把端點回的顆勾起來（可再拔）",
  ren="extend：`PermissionMenuTree.vue` 第二層（page）列的 `ml-auto` 區，當 `node.data.page.route.name==='remote-agent-console'` 多一顆 link Button（reuse 既有「只看」鈕的形狀）；呼叫 P2-1 端點後走 `props.selection` 既有 toggle，不另寫狀態",
  edit=["src/components/access/PermissionMenuTree.vue（page 列）", "src/config/api/api.js（登記型：只加一條）", "src/config/locales/i18n/{zh-tw,en}/permission-tree.json（兩個 key）", "src/components/access/__tests__/PermissionTree.spec.js"],
  new=["src/composables/useAgentConsoleMirror.js"],
  deps=["P2-1", "P1-7"], why="一顆鈕＋一支 composable；位置與形狀照既有「只看」鈕",
  accept="角色頁該子頁列右側有鈕；按下後 toast 顯示「已帶入 N 項」且對應勾選框變勾、可手動取消；其他子頁沒有這顆鈕；`npm run test -- PermissionTree` 綠；`grep border-left` 為 0；**雛形只取結構，鈕文案依 permission-tree.json 語氣重寫**"),

# ───────── P3 收口（批 6 發版 → 批 7 還原釘版＋文件） ─────────
dict(id="P3-1", phase="P3", stage=6, repo="jedi-packages", model="sonnet", effort="medium",
  title="兩支套件發版：jedi-agent-protocol 0.6.0、jedi-remote-agent 1.18.0；跑全部 tests 與三邊鏡像測試",
  ren="reuse：照 `docs/claude/jedi-packages.md` 末段發版流程與各套件 `publish.sh`",
  edit=[], new=[], deps=["P1-1", "P1-4", "P1-5", "P1-6", "P1-7", "P2-1", "P2-2"], why="機械性發版；版號已在 P0-1／P1-1 bump",
  accept="Nexus 上兩個版本可 `pip download`；protocol `tests/test_mirror.py` 含三邊綠；回寫列版號與哪些 consumer 要升（主系統、Agent）"),
dict(id="P3-2", phase="P3", stage=7, repo="main", model="sonnet", effort="medium",
  title="收尾卡：主系統與 Agent 改回釘版（protocol 0.6.0、remote-agent 1.18.0）、取消 assume-unchanged、poetry update 指定套件、用釘版 wheel 起 HEAD 實打",
  ren="reuse：P0-0 的 ENV.md 反向操作",
  edit=["pyproject.toml 75-76、132", "../evidence-agent/pyproject.toml 14", "poetry.lock", "../evidence-agent/poetry.lock"],
  new=[], deps=["P3-1"], why="機械性還原＋一次整體實打",
  accept="兩個 repo `git status` 乾淨後 pyproject 顯示釘版；`poetry update jedi-agent-protocol jedi-remote-agent` 過；主系統 HEAD 起來簽票端點 200；Agent HEAD 起來舊票（7 個名）登入 403、新票登入 200；回寫明講「出貨基線待重產」（P1-2 的 migration）"),
dict(id="P3-3", phase="P3", stage=7, repo="main", model="sonnet", effort="medium",
  title="文件：SPEC 三頁（角色管理、Agent 管理、Agent 主控台）補 26 顆與帶入鈕、Agent 手冊「角色權限」節、permission SOP 清單",
  ren="extend：`docs/spec-site/current/` 對應頁各加一段＋檔頭變更紀錄一行；`docs/user-manual/agent/13-standalone-operation.md` 權限表換 26 顆；`docs/claude/reference_permission_system_sop` 若列 Agent 顆則更新",
  edit=["docs/spec-site/current/system-admin/role-manage.md", "docs/spec-site/current/evidence/remote-agent-manage.md", "docs/spec-site/current/evidence/agent-console.md", "docs/user-manual/agent/13-standalone-operation.md"],
  new=[], deps=["P2-2"], why="量產文件，規格已定",
  accept="三頁各有一段；手冊表 26 列；`grep -n '原本\\|第 [0-9] 棒\\|這次' <四檔>` 命中 0；spec-site build 過"),
]

PHASE_NAME = {"P0": "契約", "P1": "四邊接上", "P2": "帶入鈕", "P3": "收口"}
REPO_NAME = {"main": "BE", "fe": "FE 主站／console", "jedi-packages": "jedi 套件", "agent": "evidence-agent"}

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(CARDS), "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']})"
    clash = 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}"); clash += 1
                seen[key] = c["id"]
    assert clash == 0, f"同批撞檔 {clash} 組"
    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))
