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

每張卡：id／批／repo／標題／reuse-extend-new／改哪些既有檔／新開哪些檔／依賴／驗收／model／effort／理由。
批（Stage）＝能同時跑的那一組：不改同一檔、不依賴對方產出、不搶同一外部資源。
登記型檔各只讓一張卡碰：BE `event_code.py`／`security_policy.py`（jedi-iam）／`remote_agent.py` adapters 都在 B1；
FE 主站 `api.js` 不動（端點不變，只多 header）；FE 主站 i18n 由 F1 一次佔好 key。
repo 值：main（BE）、fe、jedi-packages、agent。
"""

REPO_NAME = {"main": "BE", "fe": "FE", "jedi-packages": "jedi 套件", "agent": "evidence-agent"}

CARDS = [
# ───────── 批 1：新套件 ─────────
dict(id="P1", stage=1, repo="jedi-packages", model="opus", effort="high",
  title="新套件 jedi-file-protect：產密碼、PDF／Office／7z 三種加密、PDF 與 docx 頁尾浮水印（零框架相依）",
  ren="new：照 jedi-masking 形狀開一支「一件事、零框架相依」的小套件（只相依 pypdf、msoffcrypto-tool、py7zr、python-docx、cryptography）。既有 `cryptography` Fernet 是欄位加密不是檔案加密，不適用；既有結案報告「草稿」浮水印是 docx 範本變數，只適用該範本，故套件另提供通用頁尾浮水印。政策、稽核、密碼顯示畫面都不進套件（宿主的事）",
  edit=[],
  new=["jedi-file-protect/pyproject.toml", "jedi-file-protect/jedi_file_protect/__init__.py", "jedi-file-protect/jedi_file_protect/password.py（generate_password：16 碼英數四段、去 0O1lI、`secrets`）", "jedi-file-protect/jedi_file_protect/protect.py（protect(data, filename, password, *, watermark=None) → ProtectedFile(data, filename, needs_7zip: bool)；依副檔名分派）", "jedi-file-protect/jedi_file_protect/pdf.py（pypdf AES-256 R6；禁 RC4／R5）", "jedi-file-protect/jedi_file_protect/office.py（msoffcrypto agile 加密 docx／xlsx／pptx）", "jedi-file-protect/jedi_file_protect/archive.py（py7zr AES-256、header 加密，多檔與其他副檔名）", "jedi-file-protect/jedi_file_protect/watermark.py（PDF 頁尾小字、docx 頁尾小字）", "jedi-file-protect/tests/", "jedi-file-protect/README.md", "jedi-file-protect/publish.sh"],
  deps=[], why="密碼學與三種格式的細節陷阱多（R5、ZipCrypto、agile 版本），要能辨識錯誤選項",
  accept="套件 tests：pdf 加密後 `pypdf` 不帶密碼開啟 raise、帶密碼開啟成功且 `/R` 為 6；docx／xlsx 加密後 `msoffcrypto` 回 `is_encrypted()` True 且用密碼解回原 bytes；7z 不帶密碼 `py7zr` 列檔名 raise（header 加密）；password 1 萬次無 0O1lI、長度 19、四段；突變：把 pdf 改 R5 測試紅、把 7z header 加密拿掉測試紅；`pip show` 相依不含 Flask／SQLAlchemy；浮水印：PDF 每頁最後一行含下載者字串、docx footer 含同字串"),

# ───────── 批 2：主系統接線＋FE 共用對話框（兩 repo 平行） ─────────
dict(id="B1", stage=2, repo="main", model="opus", effort="high",
  title="主系統：出檔前保護 helper、安全政策開關 FILE_DOWNLOAD_PROTECTION、心跳下發、FILE_DOWNLOADED 事件碼、先接稽核計畫與結案報告兩個端點",
  ren="new：`common/util/file_protect.py` 的 `send_protected(buf, filename, mimetype, *, request, watermark_actor)`——讀 header `X-File-Password`、查開關、呼叫套件、寫稽核、回 `send_file`＋`Cache-Control: no-store`＋`X-Filename`＋`X-File-Protected`；取代各端點手寫的 `_send_attachment`（ap_export、ssp_export、mf_ssp_export 三支已各有一份，本卡抽成一份）。extend：jedi-iam `security_policy.py` `BOOL_KEYS`／`_STATIC_DEFAULTS` 加 `FILE_DOWNLOAD_PROTECTION=True`（套件改動，開發期 path dependency）；`core/plugins/remote_agent.py` `heartbeat_extras` 加 `file_protect_heartbeat_extra`；`event_code.py` 加 `FILE_DOWNLOADED`；`ap_export_route.py`、`closing_report_route.py` 改呼叫 helper",
  edit=["jedi-iam/jedi_iam/domain/security_policy.py（BOOL_KEYS、_STATIC_DEFAULTS）", "pyproject.toml（jedi-file-protect pin、jedi-iam path 開發期）", "common/enum/event_code.py", "core/plugins/remote_agent.py（heartbeat_extras）", "api/project/routes/ap_export_route.py", "api/closing_report/routes/closing_report_route.py", "app/system_config/service/security_policy_app_service.py（開關關閉寫稽核 SECURITY_POLICY_UPDATED 帶 file_download_protection=False）"],
  new=["common/util/file_protect.py", "core/plugins/_agent_file_protect.py（心跳 extra：`{\"file_protect\": {\"enabled\": bool}}`）", "test/test_file_protect_helper.py"],
  deps=["P1"], why="跨 helper／政策／心跳／稽核四處接線，開關關閉時的行為要一次對齊",
  accept="DEV 重啟後：`GET /ap/<uid>/export?format=docx` 不帶 header 回 400（GRC_400xxx 新碼「缺少檔案密碼」），帶 `X-File-Password` 回 docx 且 Word 要密碼、回應 header 有 `Cache-Control: no-store` 與 `X-File-Protected: 1`；`format=zip` 回 `.7z` 且 7-Zip 要密碼、`X-File-Needs-7zip: 1`；結案報告 pdf 同；稽核事件頁出現 FILE_DOWNLOADED（使用者、檔名、encrypted=true）且 payload 無密碼；`grep -rn X-File-Password log/app.log` 為 0；安全政策 PUT `FILE_DOWNLOAD_PROTECTION=false` 後同端點不帶 header 直接給明文、事件 encrypted=false；心跳回應 `data.file_protect.enabled` 跟著變；守衛測試綠；回寫明講 jedi-iam 已改、發版前要確認"),
dict(id="F1", stage=2, repo="fe", model="opus", effort="medium",
  title="主站共用 composable useProtectedDownload ＋「密碼只顯示一次」對話框元件 ＋ 安全政策開關段 ＋ 先接稽核計畫與結案報告",
  ren="new：`src/composables/useProtectedDownload.js`（先問 `/security-policy` 開關快取；開啟→開 `ProtectedDownloadDialog` 產密碼（`crypto.getRandomValues`，16 碼四段去 0O1lI）→確認後 axios blob 帶 `X-File-Password`；關閉→直接下載；檔名用既有 `filenameFromHeaders`、下載用既有 `downloadBlob`），取代各 composable 內重複的 axios blob 段。new：`src/components/common/ProtectedDownloadDialog.vue`（照雛形①：提示列、密碼框、複製後下載鈕才亮、7z 提醒依 `needs7zip` 顯示）。extend：`SecurityPolicyForm.vue` 登入段後加「檔案下載保護」InputSwitch（照 MFA 那段形狀）＋關閉時頁頂 Message 警示；`useApExportDownload.js`、`useClosingReport.js` 改走新 composable。i18n 兩語言 key 本卡一次佔好（含 F2 會用的）",
  edit=["src/composables/useApExportDownload.js", "src/composables/useClosingReport.js", "src/views/security-policy/SecurityPolicyForm.vue", "src/config/locales/i18n/zh-tw/security-policy.json", "src/config/locales/i18n/en/security-policy.json", "src/config/locales/i18n/zh-tw/common.json", "src/config/locales/i18n/en/common.json"],
  new=["src/composables/useProtectedDownload.js", "src/components/common/ProtectedDownloadDialog.vue"],
  deps=["P1"], why="共用元件的形狀決定後面 24 處怎麼接，要對照雛形把互動做對",
  accept="稽核計畫頁按匯出 Word：對話框顯示 19 字元密碼、「下載」鈕灰到按「複製」才亮、下載後對話框關閉；DevTools Network 看請求 header 有 `X-File-Password`、URL 無密碼；`localStorage`／Pinia 無密碼；再按一次密碼不同；匯出 ZIP 對話框多 7-Zip 提醒；安全政策頁多一個開關、關掉存檔後頁頂出現警示、再按匯出不跳對話框；`grep -rn border-left src/components/common/ProtectedDownloadDialog.vue` 為 0；`http://<IP>:5173` 非 localhost 開頁實測 `crypto.getRandomValues` 可用"),

# ───────── 批 3：全部出口接上（BE／FE／Agent 三 repo 平行） ─────────
dict(id="B2", stage=3, repo="main", model="sonnet", effort="medium",
  title="主系統其餘匯出端點全部改走 send_protected（SSP、控制項對照、RACI、風險登錄、前置資料、診斷包、AI 呼叫紀錄、SSP 控制實作、範本下載、來源掃描、PoA&M、稽核結果、通過報告、Agent 報告與檔案包、弱點庫包除外）",
  ren="extend：每支端點把自己的 `send_file`／`_send_attachment` 換成 B1 的 `send_protected` 一行；刪掉 ssp_export、mf_ssp_export 各自的 `_send_attachment` 複本。不接：`agent_vulndb_route`（Agent 機器對機器拉的包，不是人下載）、`oscal_framework_version_route` OSCAL json 下載（框架公開內容，非機敏；回寫列出）、`poam_objective_route` json 若為機器匯出同上",
  edit=["api/oscal/routes/ssp/ssp_export_route.py", "api/module_frame/routes/mf_ssp_export_route.py", "api/oscal/routes/control_mapping_route.py", "api/governance/routes/raci_defaults_io_route.py", "api/governance/routes/risk_register_route.py", "api/pre_engagement/routes/file_route.py", "api/support/routes/diagnostic_bundle_route.py", "api/ai_call_log/routes/ai_call_log_route.py", "api/oscal/routes/ssp/ssp_control_impl_import_route.py", "api/module_frame/routes/ssp_import_template_route.py", "api/module_frame/routes/module_frame_template_import_route.py", "api/source_scan/routes/scan_route.py", "api/project/routes/ar_workbook_route.py", "api/project/routes/pass_report_route.py", "api/project/routes/poam_objective_route.py", "api/project_summary_report/routes/project_summary_report_route.py", "api/remote_agent/routes/agent_local_run_route.py", "api/remote_agent/routes/agent_file_route.py"],
  new=[], deps=["B1"], why="機械性替換，形狀 B1 已定；端點多但每支一行",
  accept="`grep -rn 'send_file\\|as_attachment' api/ | grep -v file_protect | grep -v vulndb | grep -v framework_version` 只剩回寫列出的例外；每支端點實打一次：不帶 header 400、帶 header 回加密檔；`tar.gz` 診斷包改回 `.7z`；SSP 匯出 docx／pdf／odt 三格式都要密碼（odt 走 7z 包，回寫註明）；守衛測試綠"),
dict(id="F2", stage=3, repo="fe", model="sonnet", effort="medium",
  title="主站其餘 24 處下載全部改走 useProtectedDownload（service 層 blob 與頁面內 axios blob 都收進 composable）",
  ren="extend：`src/service/*Service.js` 內回 blob 的方法改回 `{url, params}` 讓 composable 打，或直接在頁面改呼叫 `useProtectedDownload().download(url, params, fallbackName)`；頁面內自己寫 axios blob 的段落刪掉改呼叫。不接：裝置匯入範本、使用者匯入範本這類「空白範本」（非機敏，回寫列出）；確認每個下載按鈕只剩一條路徑",
  edit=["src/composables/useAgentLocalRuns.js", "src/composables/useArExportDownload.js", "src/composables/useClosingReportBundle.js", "src/composables/useCraLegalDocsExport.js", "src/composables/usePassReportDownload.js", "src/service/ControlMappingService.js", "src/service/GovernanceService.js", "src/service/ModuleFrameTemplateService.js", "src/service/PreEngagementService.js", "src/service/SourceScanService.js", "src/service/SspService.js", "src/service/SupportService.js", "src/views/action-log/UserLogs.vue", "src/views/ai-call-log/AiCallLogList.vue", "src/views/ai-call-log/AiUsageSummary.vue", "src/views/device/DeviceExportMenu.vue", "src/views/feedback/system/SystemFeedbackManage.vue", "src/views/project-summary-report/ProjectSummaryReportForm.vue", "src/views/project/ProjectPlanningView.vue", "src/views/project/RoundPoamPlanView.vue"],
  new=[], deps=["F1"], why="機械性替換，每處改成一行呼叫",
  accept="`grep -rln \"responseType: 'blob'\" src | grep -v useProtectedDownload` 只剩回寫列出的例外；翻一遍每個有下載鈕的頁面都跳對話框；`ProjectPlanningView.vue` 3,540 行超標只准淨減，新邏輯不往裡加"),
dict(id="A1", stage=3, repo="agent", model="opus", effort="medium",
  title="Agent 主控台：檔案與報告下載走 jedi-file-protect、心跳收 file_protect 開關離線沿用、本機稽核 AGENT_FILE_DOWNLOADED 帶 encrypted",
  ren="extend：`core/console/files.py` 兩條下載與 `ops_routes.py` 診斷包下載改呼叫一支 Agent 側 helper `core/console/file_protect.py`（讀 header、查本機快取的開關、呼叫套件、稽核；與主系統 helper 同介面但不共用程式——主系統 helper 綁 Flask-RESTful 與 EventCode）；心跳 observer 照 `ldap_source.py` 形狀收 `data.file_protect`，寫 `data/file_protect.json`，沒帶（舊主系統）視為開啟；`pyproject.toml` 加 `jedi-file-protect`。前端在 FE repo `console/`，由 F3 做",
  edit=["core/console/files.py", "core/console/ops_routes.py", "core/console/heartbeat.py（掛 observer）", "pyproject.toml"],
  new=["core/console/file_protect.py", "core/file_protect_sync.py（心跳 observer＋本機快取）", "tests/test_file_protect.py"],
  deps=["P1"], why="Agent 離線語意（沒帶視為開啟、最後一份沿用）要判清楚，錯了離線期間整台不加密",
  accept="Agent 本機：下載不帶 header 400、帶 header 回加密檔；拔主系統後重啟 Agent 仍加密（讀快取）；主系統關開關→下一跳後不加密、`data/file_protect.json` 為 false；開回來下一跳恢復；從未連過主系統的新 Agent 預設加密；`system_logs` 有 AGENT_FILE_DOWNLOADED 帶 encrypted 欄且無密碼；套件 tests 綠"),

# ───────── 批 4：主控台前端；批 5：收尾文件 ─────────
dict(id="F3", stage=4, repo="fe", model="sonnet", effort="medium",
  title="Agent 主控台前端：檔案頁、執行紀錄、診斷包下載接 ProtectedDownloadDialog（reuse F1 元件，不另刻）",
  ren="reuse：`console/` 直接 import `src/components/common/ProtectedDownloadDialog.vue` 與 `useProtectedDownload`（主控台與主站同一個 Vite 專案，可共用；若 console 有獨立 i18n 命名空間則把 key 複製一份）；開關值從 Agent 既有主控台狀態端點讀（A1 若沒暴露就加一個欄位，回寫註明）；不新刻第二個對話框",
  edit=["console/views/Files.vue", "console/views/Runs.vue（或執行紀錄對話框）", "console/views/Ops.vue（診斷包）", "console/i18n（若獨立）"],
  new=[], deps=["F1", "A1"], why="接線為主，元件現成",
  accept="主控台檔案頁下載跳同一個對話框（含 7z 提醒）；離線狀態下仍跳、仍能下載加密檔；主系統關開關後下一跳對話框不跳；`grep -rn 'ProtectedDownloadDialog' console/` 命中且 `console/` 內無第二份對話框元件"),
dict(id="D1", stage=5, repo="main", model="sonnet", effort="low",
  title="文件：SPEC 頁（匯出類頁面各加一段「下載會加密」、安全政策頁）、使用手冊「如何開啟加密檔」、套件 README、design.md 開頭交棒句",
  ren="extend：`docs/spec-site/current/` 對應頁加段落與檔頭變更紀錄一行；使用手冊新增一頁「下載的檔案要密碼？」（Word／Excel／PDF／7z 四種怎麼開、7-Zip／Keka 下載連結、忘記密碼重新匯出）",
  edit=["docs/spec-site/current/system-admin/security-policy.md", "docs/spec-site/current/（匯出類頁面，runner grep '匯出' 列出）", "docs/user-manual/"],
  new=["docs/user-manual/<開啟加密檔>.md"],
  deps=["B2", "F2", "F3"], why="量產文件，規格已定",
  accept="MkDocs build 無警告；手冊頁有四種格式各一段與截圖位置；`grep -n \"原本\\|改判\\|第 [0-9] 棒\" <改到的 spec 頁>` 為 0"),
# ───────── 批 6：首腦母卡層總驗抓到的三個缺口（BE／FE 兩 repo 平行；2026-10-29 補卡） ─────────
dict(id="B3", stage=6, repo="main", model="sonnet", effort="low",
  title="Agent 下載稽核的 encrypted 欄位進主系統（_FIELDS 白名單加一欄、bool 不被 _clean 丟掉）＋ SPEC／手冊補總覽警示與未加密標記",
  ren="extend：`core/plugins/_agent_console_audit.py` `_FIELDS` 加 `encrypted`；`_clean` 目前把 bool 一律丟成 None（`git log -L` 查原因），encrypted 要能進 message（轉 `true`／`false` 字串或 encrypted 專用處理），不要另寫第二支 sink。文件：F4 做完後 security-policy.md 與手冊兩處補回「總覽常駐警示、稽核頁標未加密」（D1 第 2 輪為照實寫先拿掉）",
  edit=["core/plugins/_agent_console_audit.py（_FIELDS、_clean 對 bool）", "test/test_agent_console_audit_sink.py", "docs/spec-site/current/system-admin/security-policy.md", "docs/user-manual/encrypted-download-guide.md", "docs/spec-site/current/user-manual/encrypted-download-guide.md"],
  new=[],
  deps=["A1", "D1"], why="一欄一測試，但 _clean 丟 bool 是陷阱，要讀懂再改",
  accept="Agent 主控台下載一次後，主系統稽核事件頁 AGENT_FILE_DOWNLOADED 那筆 message 含 `encrypted=true`（開關關閉時 `encrypted=false`）；既有 sink 測試全綠且新增一條「encrypted 進 message、沒帶時不出現」；兩份手冊 diff 一致"),
dict(id="F4", stage=6, repo="fe", model="sonnet", effort="medium",
  title="D5 另兩道警示：系統管理員看得到的「檔案下載保護已關閉」頂部橫幅（總覽與全站常駐）＋ 稽核事件頁每筆下載標「未加密」",
  ren="reuse：`securityPolicyStore.protectionOn`（F2 已改讀 `/system/file-download-protection`）、`LicenseExpiryBanner` 掛在 `AppLayout.vue` 的位置與寫法（照它新開一支 `FileProtectionOffBanner.vue`，不塞進 LicenseExpiryBanner）、i18n 已預留 `protected_download.unprotected_badge` 與 `security_policy.file_download_protection_off_warning`。extend：`UserLogs.vue` request／message 欄對 FILE_DOWNLOADED 與 AGENT_FILE_DOWNLOADED 且 `encrypted=false` 的列加一顆 Tag（severity warning）。誰看得到橫幅：用側欄對「安全政策」頁的同一個能力點判斷，不自己發明角色判斷",
  edit=["src/layout/AppLayout.vue（掛一行）", "src/views/action-log/UserLogs.vue"],
  new=["src/components/security/FileProtectionOffBanner.vue"],
  deps=["F1", "F2"], why="兩處 UI 接線，規格在雛形畫面②b 與④，照做即可",
  accept="安全政策關掉存檔後：總覽與任何頁頂部出現常駐橫幅（系統管理員帳號看得到、一般使用者看不到），開回來橫幅消失；稽核事件頁 FILE_DOWNLOADED／AGENT_FILE_DOWNLOADED 且 encrypted=false 的列有「未加密（保護已關閉）」Tag，encrypted=true 的列沒有；`grep -rn border-left` 新檔為 0；vitest 既有綠"),
# ───────── 批 7：決策者實測證據附件沒加密後的全站出口盤點（2026-10-29 補卡；上傳檔那條首腦已自己修） ─────────
dict(id="P2", stage=7, repo="jedi-packages", model="sonnet", effort="medium",
  title="三支套件的人用匯出端點加 download_responder 形狀的掛點：jedi-api-log 操作日誌匯出、jedi-issue 意見回饋匯出、jedi-task-platform 任務批次匯出（主系統注入 send_protected）",
  ren="extend：照 jedi-file-upload 1.5.0 `FileUploadAdapters.download_responder` 的形狀，三支套件各在 adapters 加一個選填 `download_responder(file_obj, file_name, mimetype)`，route 的 `send_file` 改成「有注入交給它、沒注入照舊」；不在套件裡認得加密政策。不接：jedi-iam 使用者匯入範本與 TOTP QR 圖（非機敏）、jedi-asset 裝置匯入範本、jedi-evidence-classification 預覽（inline 不落磁碟）、jedi-detection 報告（主系統 Agent 報告端點已接）",
  edit=["jedi-api-log/.../api_log_route.py ExportApiLogsRoute", "jedi-issue/.../feedback_route.py FeedbackExportRoute", "jedi-task-platform/.../job_import_route.py JobExportRoute", "三支套件 plugin/contract.py（adapters 欄位）、README adapters 表、pyproject version、tests"],
  new=[],
  deps=[], why="照既有形狀複製三次，判斷在於每支套件的 adapters 長得略不同，要對照各自的 contract",
  accept="三支套件 contract 測試各加一條「注入 responder 時交給它、未注入 send_file」；版號各進中版號（功能）；回寫列出每支套件 adapters 欄位名與未接清單；不發版、不推 Nexus（發版由首腦統一做）"),
dict(id="B4", stage=8, repo="main", model="sonnet", effort="low",
  title="主系統注入三支套件的 download_responder（操作日誌、意見回饋、任務批次匯出改走 send_protected）＋FE 三處下載改走 useProtectedDownload",
  ren="reuse：`core/plugins/_file_upload_download.protected_download_responder` 直接給三支套件用（同一支函式、source 改成各自代號可加參數）；三支 core/plugins/<pkg>.py build_adapters 各加一行。FE：操作日誌匯出、意見回饋匯出、任務批次匯出三處改 `useProtectedDownload().download(...)`（FE 改動另開一張卡或由首腦決定併卡，本卡只列 BE）",
  edit=["core/plugins/api_log.py", "core/plugins/issue.py", "core/plugins/task.py", "core/plugins/_file_upload_download.py（source 參數化）", "pyproject.toml（三支套件釘版，發版後）"],
  new=[],
  deps=["P2"], why="三行接線加釘版，照 B3 的量",
  accept="DEV 重啟後三個匯出端點不帶密碼 400、帶密碼回加密檔且 X-File-Protected: 1；稽核 source 各自代號；`grep -rn send_file( .venv/lib/python3.11/site-packages/jedi_*/ --include=*.py | grep -v tests` 剩下的全是不接清單上的"),
]

def check():
    by_stage = {}
    ids = {c["id"] for c in CARDS}
    for c in CARDS:
        assert all(d in ids for d in c["deps"]), c["id"]
        by_stage.setdefault(c["stage"], []).append(c)
    stage_of = {c["id"]: c["stage"] for c in CARDS}
    for c in CARDS:
        for d in c["deps"]:
            assert stage_of[d] < c["stage"], (c["id"], d)
    for st, cs in by_stage.items():
        seen = {}
        for c in cs:
            for e in c["edit"]:
                key = (c["repo"], e.split(" ")[0].split("（")[0])
                assert key not in seen, (st, key, seen.get(key), c["id"])
                seen[key] = c["id"]
    return True

if __name__ == "__main__":
    check(); print("ok", len(CARDS), {s: [c["id"] for c in CARDS if c["stage"] == s] for s in sorted({c["stage"] for c in CARDS})})
