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

🔴 只放開發卡（決策者 2026-11-11）：端到端驗收、套件發版、改回釘版不開卡，見 design.md §7「合回後收口清單」。

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

CARDS = [
# ───────── 階段 P0 前置 ─────────
dict(id="E-1", phase="P0", stage=1, repo="main", model="sonnet", effort="medium",
  title="環境卡：主系統開發期改走四套件本機路徑（jedi-agent-protocol／jedi-issue／jedi-remote-agent／jedi-iam）；FR-190／FR-188 合回後重做，BE 工作區共用 .venv 要先處理",
  ren="reuse：既有 poetry path dependency 形式（pyproject.toml 第 132～135 行註解掉的那組）；指向各套件工作坊工作區 `.worktrees/<母卡號>/<套件>`",
  edit=["pyproject.toml（不 commit，`git update-index --assume-unchanged`）"], new=[],
  deps=[], why="機械性：改四行、poetry update 指定套件名、驗 import 路徑",
  accept="`python main.py` 起得來；`python -c 'import jedi_issue,jedi_agent_protocol,jedi_remote_agent,jedi_iam;print(*(m.__file__ for m in (jedi_issue,jedi_agent_protocol,jedi_remote_agent,jedi_iam)),sep=chr(10))'` 四行都在 `.worktrees/` 底下；`git status` 不見 pyproject.toml"),
dict(id="P-1", phase="P0", stage=1, repo="jedi-packages", model="sonnet", effort="medium",
  title="jedi-agent-protocol：回饋端點路徑、動作列舉、鍵名清單、兩個設定類；0.6.0 → 0.7.0（0.6.0 已被 FR-190 發版用掉）",
  ren="extend：照 `tasks.py` 既有 `LOCAL_RUNS_PATH` 與 `config.py` 既有 KIND_* 常數形狀加項，不另開檔",
  edit=["jedi-agent-protocol/jedi_agent_protocol/tasks.py（FEEDBACKS_PATH、FEEDBACK_OPS、FEEDBACK_ITEM_KEYS、FEEDBACK_FILE_KEYS、FEEDBACKS_MAX_BATCH）", "jedi-agent-protocol/jedi_agent_protocol/config.py（KIND_FEEDBACK_LABELS、KIND_FEEDBACKS 進 ALL_KINDS）", "jedi-agent-protocol/jedi_agent_protocol/__init__.py（export）", "jedi-agent-protocol/pyproject.toml"],
  new=[], deps=[], why="純契約常數；一次定完兩端都照它寫",
  accept="以 FR-190 合回後的 0.6.0（含 console_scopes）為底；套件 tests 綠；`python -c 'import jedi_agent_protocol as p; assert p.FEEDBACKS_PATH and p.CONSOLE_SCOPES'` 過；`tests/test_mirror.py` 三邊綠；版號 0.7.0"),
dict(id="P-2", phase="P0", stage=1, repo="jedi-packages", model="sonnet", effort="low",
  title="jedi-iam：保留字 `local-admin` 在每一條寫入帳號名的路徑都擋（建、改名、匯入、批次匯入、LDAP／AD 同步、佈建）；1.11.2 → 1.11.3",
  ren="extend：保留字檢查抽成 domain 一支 `assert_login_name_not_reserved()`，建帳號、改帳號名、LDAP／AD 同步建帳號、租戶佈建四個寫入點都呼叫它（不是只在 `verify_login_name_duplicate` 旁加一行）；錯誤碼走既有 login_name 不合法那顆",
  edit=["jedi-iam/jedi_iam/app/service/user_service.py 158（建）、273～323（update_user）", "jedi-iam/jedi_iam/app/service/import_user_service.py 172", "jedi-iam/jedi_iam/app/service/user_batch_import_service.py", "jedi-iam/jedi_iam/app/service/tenant_provisioning_service.py", "jedi-iam LDAP／AD 同步建帳號處（FR-188 合回後再 grep 一次）", "jedi-iam/pyproject.toml（1.11.2 → 1.11.3，在 FR-188 之上）"],
  new=["jedi-iam/jedi_iam/domain/.../reserved_login_names.py（集合＋檢查函式）"],
  deps=[], why="四個寫入點一起擋才算擋；漏一條就是資安審查指的碰撞洞",
  accept="套件 tests：建、改名、LDAP 同步、佈建四條路徑給 `local-admin`（含大小寫變體）都拋既有不合法錯誤；其他名字不受影響；突變（把檢查拿掉任一路徑要紅）；回寫附 DEV 唯讀查詢 `select count(*) from public.users where lower(login_name)='local-admin'`（開卡時為 0）"),

# ───────── 階段 P1 看得見的 ─────────
dict(id="A-1", phase="P1", stage=2, repo="agent", model="opus", effort="high",
  title="Agent 新模組 feedback：表、新增／清單／單筆、主控台 API、outbox 類型與保序、類別選項套用者",
  ren="new（新模組，照型）：表欄位與 app 函式簽名照 `scan/app/local_runs.py`（`_insert`／`record_sync`／`list_page`／`get`）；console API 照 `console/api/runs_routes.py`（清單、404 不分查無與非本人、resend）；附件 reuse `files/app/blob_ops.save_upload`；extend：outbox 加 KIND_FEEDBACK 進 `_NEVER_DROP`／`_ORDERED`／`SENDABLE`，批次送 FEEDBACKS_PATH 照 `_post_local_runs`",
  edit=["guidant_agent/sync/ports/result_outbox.py（KIND_FEEDBACK）", "guidant_agent/sync/app/outbox.py（三個集合＋`_post_feedbacks`）", "guidant_agent/platform/registry.py（MODULES 加 `_module(\"feedback\", migrations=True, config=True)`；模組 `__init__.py` 申報 `config_appliers()` 照 identity）", "guidant_agent/shared/event_codes.py（AGENT_FEEDBACK_CREATED／SYNCED／SYNC_FAILED）", "common/code/（新 ConsoleFeedbackErrorCode 檔）"],
  new=["guidant_agent/feedback/__init__.py", "guidant_agent/feedback/migrations.py（新版號：feedbacks 表）", "guidant_agent/feedback/app/feedbacks.py（create／list_mine／get／record_sync）", "guidant_agent/feedback/app/labels.py（KIND_FEEDBACK_LABELS 套用者，存 data_dir JSON 檔照 identity/app/offline_login_window.py；Agent 沒有 kv 表）", "guidant_agent/feedback/adapters/feedback_repo.py（SQL 只在這）", "guidant_agent/feedback/api/feedback_routes.py（GET list／GET one／POST／POST resend／GET labels）", "test/feedback/test_feedbacks.py"],
  deps=["P-1"], why="基底＝FR-190（console 權限名）與 FR-188（outbox 立即送）合回後；新模組要過六支架構守衛（依賴方向、SQL 只在 adapters、背景元件、security rules paths）；outbox 保序改動碰核心",
  accept="守衛六支綠（含 `test_security_rules_cover_sources.py` 新檔都被命中）；主控台 `POST /console/api/feedbacks` multipart 建一筆，`GET /console/api/feedbacks` 只回本人的，換另一個 session 打單筆 404；outbox 表多一列 kind=feedback；同一 uid 入列 create 再 update，`drain` 時 create 沒送成 update 不送（test）；離線時 resend 回 409；設定類 feedback_labels 套用後 `GET /console/api/feedback-labels` 回三個"),
dict(id="I-1", phase="P1", stage=2, repo="jedi-packages", model="opus", effort="high",
  title="jedi-issue：回饋表加來源兩欄、建改回饋收既有附件參照、歸屬例外、來源查詢、清單吐來源與篩選；1.5.0 → 1.6.0",
  ren="extend：`FeedbackService.add_feedback`／`update_feedback` 加 `attached_file_uids`／`source`／`source_ref`（有參照時走 `IssueUploadFilesRepoImpl.add_issue_file_mapping` 不走 `upload_attachment`）；`_assert_owner` 加一條例外；entity／DTO／serializer 各加兩欄；query entity 加 `source` 篩選；新 `list_by_source`",
  edit=["jedi-issue/jedi_issue/app/feedback/service/feedback_service.py 175（_assert_owner）、243（add）、294（update）", "jedi-issue/jedi_issue/app/feedback/service/feedback_issue_service.py（create_issue 傳參照）", "jedi-issue/jedi_issue/app/feedback/dto/feedback_issue_dto.py", "jedi-issue/jedi_issue/domain/feedback/entities/feedback_issue_entity.py", "jedi-issue/jedi_issue/domain/feedback/entities/feedback_issue_query_entity.py", "jedi-issue/jedi_issue/domain/feedback/service/feedback_issue_domain_service.py", "jedi-issue/jedi_issue/infra/feedback/models/feedback_issue.py", "jedi-issue/jedi_issue/infra/feedback/repository/（list_by_source）", "jedi-issue/jedi_issue/api/serializers/feedback.py（source／source_ref 進 FeedbackResponse；FeedbackQueryRequest 加 source）", "jedi-issue/jedi_issue/api/routes/feedback_route.py 65、76（清單與單筆回應先過 `rt.schema_extensions.enrich()`——掛點是空殼從沒人呼叫，不接 M-1 整張白做；post/put 簽名不動）", "jedi-issue/pyproject.toml"],
  new=["jedi-issue/jedi_issue/migrations/006-feedback-issues-source.sql"],
  deps=[], why="套件公開 API 加參數、表加欄、歸屬檢查改動；要保既有 consumer 零影響",
  accept="套件 tests 綠；新 tests：`add_feedback(attached_file_uids=[x])` 不呼叫 upload 直接掛 mapping；`source` 預設 `web`；`_assert_owner` 對 `source=agent, created_user=local-admin` 持 read_all 者放行、不持者 403（突變：把例外拿掉要紅）；`list_by_source('agent', uid)` 只回該 Agent 的；DEV 套 006 後 `\\d public.feedback_issues` 有 source／source_ref 與索引；既有回饋 source 全為 web"),
dict(id="F-1", phase="P1", stage=2, repo="fe", model="opus", effort="medium",
  title="FE 共用附件元件 AttachmentDropList：一個拖放區、既有檔與待上傳檔同清單、hover 動作、存放位置標籤；先換進主系統回饋表單",
  ren="new（照型）：拖放與 accept 照 `src/views/source-scan/SourceFilePicker.vue`（`ss-drop`、`onDrop`、`useAcceptedUploadTypes`），多檔清單與既有檔列是新寫；extend：`SystemFeedbackForm.vue` 附件區整段換成這個元件，`FileUpload` 兩區四樣式刪掉",
  edit=["src/views/feedback/system/SystemFeedbackForm.vue 302～420（附件區）", "src/config/locales/i18n/zh-tw/feedback.json、en/feedback.json（附件區文案改寫；舊鍵 drag_drop_hint／ready_to_upload／uploaded_files 若無他處用則刪）"],
  new=["src/components/common/AttachmentDropList.vue（props：existing[]、pending[]、accept、maxSize、canDownload、canDelete、readonly；emits：add、remove-pending、remove-existing、preview、download）"],
  deps=[], why="元件 API 要讓主站與主控台都能用；版面照雛形結構不抄樣式；套全站 dark-card 與字級",
  accept="`npm run build` 過；主系統新增回饋：空的只有一行提示、拖兩個檔進區內兩列、hover 出移除、超 20 MB 顯示既有錯誤訊息；編輯既有回饋：既有檔與新加檔同一區、既有檔 hover 出預覽／下載／刪除且 v-can 照舊；`grep -n 'border-left\\|font-size\\|max-width' src/components/common/AttachmentDropList.vue` 為 0；文案全部來自 i18n 檔，回寫列出新增與沿用的鍵"),

# ───────── 階段 P2 接起來 ─────────
dict(id="M-1", phase="P2", stage=3, repo="main", model="sonnet", effort="medium",
  title="主系統來源欄顯示：接 jedi-issue 回應擴充掛點補 Agent 名與「本機管理員」暱稱；功能標籤「Agent 主控台」seed",
  ren="reuse：jedi-issue `SchemaExtensions.response_enricher`（空殼掛點第一次有人接）；extend：`core/plugins/issue.py` `mount()` 傳 schema_extensions；seed 照 `2026-05-22-seed-oscal-ssp-roles.sql` 形狀 `ON CONFLICT DO NOTHING`",
  edit=["core/plugins/issue.py 184（mount 加 schema_extensions）"],
  new=["core/plugins/_issue_source_enrich.py（source=agent 補 source_name＝remote_agents.name；created_user=local-admin 且 source=agent 補 created_user_name=本機管理員）", "scripts/sql/2026-11-XX-fr192-seed-agent-console-function-label.sql（labels：scope=FUNCTION, name=Agent 主控台，uid 固定）", "common/code/feedback_source.py（AGENT_CONSOLE_FUNCTION_LABEL_UID、SOURCE_WEB／AGENT、LOCAL_ADMIN_LOGIN_NAME）"],
  deps=["I-1"], why="接線與 seed，形狀都有先例。🔴 登記型共用檔 manifest.tsv 由同批 M-3 一次登記兩支（本卡 seed＋M-3 的 014 鏡像），本卡只寫 seed 檔、不碰 manifest",
  accept="DEV 套 seed 後 `select uid,name from labels where scope='FUNCTION' and name='Agent 主控台'` 一列且 uid 等於常數；手動 insert 一筆 source=agent 的回饋後 `POST /feedbacks` 回應該列有 `source_name`；created_user=local-admin 那列 `created_user_name` 為「本機管理員」；既有 web 回饋回應不變；BE 重啟實打；回寫明講「出貨基線待重產」"),
dict(id="M-3", phase="P2", stage=3, repo="main", model="opus", effort="high",
  title="主系統收單端點 POST /agents/feedbacks：新增／修改／刪除、過期判定、Agent 附件建參照、對照表、類別選項設定類",
  ren="new（照型）：route 照 `api/source_scan/routes/agent_manual_scan_route.py`、service 照 jedi-remote-agent `local_run_service.ingest`（通行證身分、批次、accepted／rejected）；extend：`RemoteAgentAdapter._add_reference` 抽成公開 `register_agent_blob()`，收單與既有上傳共用；設定類 provider 照 `_agent_config_sections.py` 既有 provider 形狀；對照表 migration 照 `011-remote-agent-offline-operators.sql` 的 RLS／GRANT",
  edit=["infra/upload_file/remote_agent_adapter.py 158（_add_reference → register_agent_blob 公開）", "core/plugins/_agent_config_sections.py（feedback_labels provider）", "core/plugins/remote_agent.py（route 登記一行）", "di_containers/remote_agent_container.py（intake service wiring）", "common/enum/event_code.py（AGENT_FEEDBACK_RECEIVED）", "scripts/sql/manifest.tsv（登記兩支：本卡 014 鏡像＋M-1 的 seed，M-1 不碰此檔）", "scripts/sql/packages/jedi_remote_agent/（鏡像同號 0NN）"],
  new=["api/remote_agent/routes/agent_feedback_route.py", "app/remote_agent/service/agent_feedback_intake_service.py", "common/code/agent_feedback_error_code.py", "jedi-remote-agent/jedi_remote_agent/migrations/0NN-remote-agent-feedbacks.sql（NN＝開工時套件目錄最大號＋1，FR-188 合回後至少 022；014 早被占用）", "test/test_agent_feedback_intake.py"],
  deps=["A-1", "I-1"], why="橋接兩個套件、身分與租戶邊界、附件參照、對照表唯一鍵；資安規則 endpoints／data 兩支都命中",
  accept="DEV 套 0NN 後 `\\d compliance.agent_feedbacks` 有 RLS 四指令與 `(agent_id, local_uid)` 唯一；用 123 Agent 的通行證打 50 筆 create 回 accepted 50、第 51 筆 400；重送同 local_uid 回 accepted 不重建；`created_by` 不在這台 Agent 的有效離線操作者名單（同租戶但沒被授權這台的人也算）回 rejected unknown_user；`local-admin` 放行且 feedback_issues.created_user=local-admin；update／delete 帶別台 Agent 或不存在的 local_uid 回 rejected not_found（不收 body 的 feedback_uid）；帶 files 的那筆 `upload_files` 多一列 storage_type=remote_agent、save_file_name=Agent 檔 uid、path=該 Agent base_url；update 帶舊的 base_cloud_updated_at 回 stale；delete 後 feedback 不在、對照表 deleted_at 有值；`GET /agents/config` 回 feedback_labels 三個；BE 重啟實打；守衛測試綠；回寫明講「出貨基線待重產」"),
dict(id="A-2", phase="P2", stage=3, repo="fe", model="opus", effort="medium",
  title="主控台前端 Feedback.vue：清單、新增回饋對話框、瀏覽回饋對話框、路由與 i18n；用 F-1 元件與主站 TipTapEditor",
  ren="new（照型）：表格與回傳狀態欄照 `console/views/Runs.vue`（狀態字沿用 `agent_console_runs` 的 sync 鍵）；新增表單欄位照 `src/views/feedback/system/SystemFeedbackForm.vue`（標題整行、類別＋功能並排、內文 `@/views/project-summary-report/TipTapEditor.vue`、檔案上傳 `@/components/common/AttachmentDropList.vue`）；對話框照 `console/views/Logs.vue` 的 Dialog；extend：`router.js` 加一項",
  edit=["console/router.js（feedback 項：group work、scope null）", "console/main.js（若 TipTap 需全域註冊才加）"],
  new=["console/views/Feedback.vue", "console/feedbackApi.js", "console/feedback.js（狀態對照、錯誤鍵）", "src/config/locales/i18n/zh-tw/agent-console-feedback.json、en/agent-console-feedback.json"],
  deps=["F-1", "A-1"], why="畫面照雛形結構；文案全部重寫；要同時接主站兩個元件進 console build",
  accept="`npm run build:CONSOLE` 過；側欄「作業」群組最下多「意見回饋」；按「新增回饋」開對話框，欄位與主系統表單同、功能反灰「Agent 主控台」、沒拉到類別時新增鈕反灰；儲存後清單立刻一列「待回傳」；點列開「瀏覽回饋」對話框，回傳失敗的頂部有原因與立即重試；`grep -rn '本案新增\\|審稿\\|D[0-9]\\|場景' console/views/Feedback.vue src/config/locales/i18n/*/agent-console-feedback.json` 為 0；`grep border-left console/` 為 0；回寫列出文案來源（沿用哪些鍵、新增哪些鍵）"),
dict(id="M-2", phase="P2", stage=3, repo="fe", model="sonnet", effort="medium",
  title="主系統前端：系統回饋與我的回饋兩頁加「來源」欄與分段篩選；編輯回饋表單來源提示列",
  ren="extend：兩頁 `<Column>` 各加一欄、工具列加 `SegmentButtons`（全部／網頁／Agent，照 `feedback_list_page_filter_baseline_my_tasks` 規則）、filters 加 source；`SystemFeedbackForm.vue` 讀 `source` 顯示 `hint-bar--info` 一列",
  edit=["src/views/feedback/system/SystemFeedbackManage.vue 249～318（欄）、toolbar", "src/views/feedback/system/SystemFeedbackView.vue", "src/views/feedback/system/SystemFeedbackForm.vue（頂部提示列；附件區 F-1 已換）", "src/config/locales/i18n/zh-tw/feedback.json、en/feedback.json（source_web／source_agent／source_hint）"],
  new=[], deps=["F-1", "I-1"], why="既有頁加欄加篩選",
  accept="`npm run build` 過；兩頁多「來源」欄，web 顯示「網頁」、agent 顯示「Agent · 名稱」；分段鈕切 Agent 只剩 Agent 來的；編輯 Agent 來的那筆頂部一列提示含 Agent 名與送出時間；`grep -n 'border-left' src/views/feedback/system/*.vue` 為 0"),

# ───────── 階段 P3 改刪與回拉 ─────────
dict(id="A-3", phase="P3", stage=4, repo="agent", model="opus", effort="high",
  title="Agent feedback 模組：修改、刪除、刪單一附件、回饋現況設定類套用者（較新者覆寫、已刪標記、stale 處理）",
  ren="extend：`feedbacks.py` 加 update／delete／remove_file／apply_cloud；`feedback_routes.py` 加 PUT／DELETE 兩條與刪附件；outbox 的 stale 回應處理（標 sync_error 並 `config_pull.request()`）；套用者照 `labels.py` 形狀",
  edit=["guidant_agent/feedback/app/feedbacks.py", "guidant_agent/feedback/api/feedback_routes.py", "guidant_agent/feedback/adapters/feedback_repo.py", "guidant_agent/sync/app/outbox.py（stale 分支）", "guidant_agent/shared/event_codes.py（UPDATED／DELETED／OVERWRITTEN_BY_CLOUD）", "test/feedback/test_feedbacks.py"],
  new=["guidant_agent/feedback/app/cloud_state.py（KIND_FEEDBACKS 套用者）"],
  deps=["A-1", "M-3"], why="雙向覆寫邏輯與保序；要突變測試（把較新者判斷拿掉要紅）",
  accept="主控台 PUT 改標題 → outbox 一列 op=update；DELETE → op=delete、清單不列；收到設定類 feedbacks 內某筆 cloud_updated_at 比本機新 → 本機覆寫、稽核 OVERWRITTEN_BY_CLOUD；比本機舊 → 不動；deleted=true → 本機標 deleted_at；主系統回 stale → 該筆 sync_error 為「主系統已有較新版本」且觸發一次 config_pull；守衛六支綠"),
dict(id="M-4", phase="P3", stage=4, repo="main", model="opus", effort="medium",
  title="主系統回拉：回饋現況設定類 provider、四張表改動監看、對照表刪除時間回寫",
  ren="extend：`_agent_config_sections.py` 加 feedbacks provider（用 jedi-issue `list_by_source` ＋對照表 deleted 列）；`_agent_config_notify._WATCHED` 加 feedback_issues／issue_upload_files／issue_label_mapping → KIND_FEEDBACKS、labels → KIND_FEEDBACK_LABELS（只通知該 source_ref 那台，照 `remote_agents name` 的單台通知形狀）；主系統端刪回饋時對照表寫 deleted_at（掛 jedi-issue delete 後，走 ORM after_delete 事件或 intake service 共用函式）",
  edit=["core/plugins/_agent_config_sections.py", "core/plugins/_agent_config_notify.py 27（_WATCHED）", "app/remote_agent/service/agent_feedback_intake_service.py（刪除路徑共用的 mark_deleted）", "test/test_agent_feedback_intake.py"],
  new=["core/plugins/_agent_feedback_state.py（provider 本體，照 `_agent_vulndb.py` 形狀）"],
  deps=["M-3", "I-1"], why="表改動監看要只通知那一台（不是全租戶）；provider 內容雜湊版本",
  accept="主系統改 Agent 來的回饋標題 → `agent_requests` 多一列 config_changed 只對那台 → Agent 拉 `GET /agents/config?versions=feedbacks:舊` 回新內容；改 web 來的回饋不通知任何 Agent；主系統刪該筆 → provider 回該 local_uid deleted=true；改 labels → 通知 feedback_labels；BE 重啟實打"),
dict(id="A-4", phase="P3", stage=4, repo="fe", model="sonnet", effort="medium",
  title="主控台前端：瀏覽回饋對話框加編輯／刪除、存於主系統附件標示、被主系統覆寫與 stale 提示、最後更新列",
  ren="extend：`Feedback.vue` 瀏覽對話框底部加兩鈕（編輯開同一個表單對話框帶現值；刪除走既有 ConfirmDialog 形狀）；附件列 where=cloud 顯示「存於主系統」標籤且無預覽；sync_error 為 stale 時頂部提示",
  edit=["console/views/Feedback.vue", "console/feedbackApi.js（PUT／DELETE／刪附件）", "console/feedback.js", "src/config/locales/i18n/zh-tw/agent-console-feedback.json、en/agent-console-feedback.json"],
  new=[], deps=["A-2"], why="既有頁加兩鈕與兩種狀態顯示",
  accept="`npm run build:CONSOLE` 過；瀏覽對話框有「編輯」「刪除」，編輯帶入現值、儲存後清單那筆變「待回傳」；刪除確認後清單消失；存於主系統的附件標籤正確且無預覽鈕；stale 那筆頂部有提示；`grep border-left console/` 為 0"),

]

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}
    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"]
    print("cards", len(CARDS), "stages", len(by), "clashes", clash)
    for st, cs in sorted(by.items()): print(f"批 {st}: " + ", ".join(f"{c['id']}({c['repo']})" for c in cs))
