"""FR-182 P2 拆卡底表（evidence-agent＋FE 主控台）。P3（主系統）另開，等 FR-181 合回。

與 FR-181（進行中）的撞檔核對（2026-11-07）：
- FR-181 F-2 已合回（registry.py owner 結束）；剩 A-3 動 `sync/app/command_pull.py`、`scan/api/routes/*`、
  `files/api/blob_route.py`、`scan/app/{cancel,probe}.py`、`files/app/blob_ops.py`——本案一個都不碰。
- FR-181 B-3（FE）動 `src/views/remote-agent/RemoteAgentManage.vue`＋主系統 i18n；本案 FE 只動 `console/`。
- FR-181 B-1 動主系統 `pyproject.toml`、A-4 動 jedi-detection；本案不動主系統、不動套件
  （`AGENT_STATUS_KEYS` 白名單在 jedi-agent-protocol，P3 才加 license 鍵，本期 status 先不送）。
- FR-179 A1（暫緩）動 `console/api/__init__.py:95`、`sync/app/enroll.py:384`、`console/api/pages.py`——
  P2-1／P2-2 不碰這三處（`sync/__init__.py` 不在其中，P2-1 可拆 `setup_agent_auth`）；「未開通也可登入」是 P2-3，等 A1 合回。

登記型檔 owner：`platform/registry.py`（P2-1）；`console/api/__init__.py` 檔尾 SUB_BLUEPRINTS（P2-2，
只加一行 import＋tuple 一項）；`console/api/pages.py` `_SPA_PAGES`（P2-2 加 "license" 一項）；
FE `console/router.js` PAGES（P2-2 加一項）；`.claude/rules/security/*.md` paths（P2-1）。
"""

CARDS = [
dict(id="P2-1", stage=1, repo="agent", model="opus", effort="high",
  title="Agent 驗 Agent 照：新模組 license（載入／驗章／核對機器碼／四態）、沒照整個 sync 模組不起、狀態欄位申報",
  ren="reuse：`common/integrity/verify.py` 的 `verify_payload(signed, expected_type=…)` 與編譯進去的 `PUBLIC_KEYS`（加常數 `PAYLOAD_TYPE_AGENT_LICENSE = \"agent_license\"` 到 verify.py 的型別清單旁）；`platform/fingerprint.compute_device_uuid`；`config.data_dir()`。new：`guidant_agent/license/`（照 ARCHITECTURE §3：`domain/status.py` 純函式四態 valid／notice／grace／expired＋`LicenseState` dataclass；`app/store.py` 讀寫 `AGENT_DATA_DIR/license/agent.license`（armor 或 JSON 都收，寫入原子 tmp＋rename、0600）；`app/check.py` `evaluate(cfg) -> LicenseState`：無檔→`missing`、驗章失敗→`invalid(reason)`、`payload_type` 不是 agent_license→`invalid(wrong_type)`、`machine_fingerprint` ≠ `device_uuid`→`mismatch(bound_prefix)`、其餘算四態；`app/install.py` `install(bytes) -> LicenseState`：先 evaluate 新檔內容（不落地），通過才寫檔並覆蓋，失敗保留原檔；`__init__.py` 申報 `status_fields()`（**本期回空 dict**——`AGENT_STATUS_KEYS` 白名單在 jedi-agent-protocol，P3 才加鍵；先把掛點做好）、`register(app, container)` 把 `LicenseGate` 單例放 `app.extensions[\"agent_license\"]`＋根容器 provider）。extend：`platform/registry.py` `MODULES` 加 `_module(\"license\", status_fields=True)` 放 `identity` 之後 `files` 之前（登記型，本卡 owner）；`sync/__init__.py` `setup_agent_auth` **拆兩支**（runner 實作時抓到：原函式一條龍到最後才起 8444，整支跳過主控台不起、重跑整支重掛 hook 炸）：`setup_agent_auth`（建 holder→掛守門→起 8443／8444，永遠跑）＋`start_agent_sync(cfg, device_uuid, holder, container, components)`（註冊＋九個背景元件入列＋start，閘門開才跑）；`platform/bootstrap.py` `_setup_agent_auth`：永遠呼叫前者，`gate.allows_sync()` 才呼叫後者，否則 log `sync disabled`、`AGENT_STATUS=unlicensed`、`gate.bind_opener(start_agent_sync)`——上傳後同行程起，不重啟。閘門仍只在 bootstrap 一處；放行條件 valid／notice／grace。`.claude/rules/security/sync-and-identity.md` 的 `paths` 補 `guidant_agent/license/**`（守衛 `test_security_rules_cover_sources` 會紅）。`mode=none`（demo）不閘。",
  edit=["guidant_agent/platform/registry.py（MODULES 加一列）", "guidant_agent/platform/bootstrap.py（_setup_agent_auth 閘門）", "guidant_agent/sync/__init__.py（setup_agent_auth 拆兩支）", "test/test_module_boundaries.py（業務模組清單加 license）", "docs/ARCHITECTURE.md（七改八）", "common/integrity/verify.py（加 PAYLOAD_TYPE_AGENT_LICENSE 常數）", ".claude/rules/security/sync-and-identity.md（paths 補 license/**）"],
  new=["guidant_agent/license/__init__.py", "guidant_agent/license/domain/status.py", "guidant_agent/license/app/store.py", "guidant_agent/license/app/check.py", "guidant_agent/license/app/install.py", "guidant_agent/license/app/gate.py", "test/test_license_status.py", "test/test_license_check.py", "test/test_license_gate.py"],
  deps=[], why="閘門位置、同行程重入、驗章與指紋核對三件事互相牽扯，錯了是「上傳照後要重啟」或「沒照照樣註冊」這種難查的病",
  accept="pytest：四態邊界（notify 30／grace 14 各邊）；無檔 missing；拿主系統 `.license` → invalid(wrong_type)；指紋不符 → mismatch 且 `bound_prefix` 12 碼；install 壞檔 → 原檔不動；gate：missing／expired／mismatch → `allows_sync()` False，valid／notice／grace → True；守衛 `test/test_fr166_guards.py`、`test_security_rules_cover_sources.py`、`test_no_legacy_tree.py` 綠。本機實跑：`AGENT_DATA_DIR=./data AGENT_AUTH_MODE=full python main_app.py` 無照 → log 一行 `license gate: sync disabled (missing)`、`/health` 仍 200、`grep -c \"agent enrolled\\|heartbeat\" log` 為 0；把 L1 用 CLI 簽、綁本機 `device_uuid` 的照放進 `./data/license/agent.license` 重起 → 註冊心跳照常"),

dict(id="P2-2", stage=1, repo="agent", model="opus", effort="medium",
  title="Agent 主控台「授權」分頁 API：看狀態＋機器碼、上傳照；SPA 頁登記",
  ren="new：`guidant_agent/console/api/license_routes.py`（`license_bp`，`url_prefix=\"/api/license\"`）——`GET \"\"` `@require_session(None)`（任何登入者）回 `{status, reason, license_id, customer_code, issued_to, type, starts_at, expires_at, days_to_expiry, grace_days_left, machine_fingerprint（本機 device_uuid 完整 64 碼）, bound_fingerprint_prefix, expiry_policy}`；`POST /upload` `@require_session(\"configure\")`，`body_limit.read_limited(64 KB)`、multipart 或 raw body 都收 → `license.app.install.install(bytes)` → 成功回新狀態 200、失敗 400 帶 `reason`（wrong_type／bad_signature／mismatch／expired／malformed）；兩支都 `page_viewed`／`audit`（事件碼用既有 `Code` 裡最接近的，**不加新事件碼**——`event_code.py` 是登記型檔，P3 統一加）。extend：`console/api/__init__.py` 檔尾加一行 `from ...license_routes import license_bp` 與 `SUB_BLUEPRINTS` 加一項（登記型，本卡 owner）；`console/api/pages.py` `_SPA_PAGES` 加 `\"license\"`；`console/app/overview.py` 回應加 `license: {status, expires_at}` 兩鍵（總覽頁頂端要顯示提醒）。禁：不碰 `console/api/__init__.py` 的 `create_session`／`require_session`（FR-179 A1 的範圍）。",
  edit=["guidant_agent/console/api/__init__.py（檔尾 import 一行＋SUB_BLUEPRINTS 一項）", "guidant_agent/console/api/pages.py（_SPA_PAGES 加 license）", "guidant_agent/console/app/overview.py（加 license 兩鍵）"],
  new=["guidant_agent/console/api/license_routes.py", "test/test_license_routes.py"],
  deps=[], why="兩支薄殼照 keys_routes 形狀；上傳的守門、大小上限、錯誤碼對映要照資安規則",
  accept="pytest：未登入 401；`files` scope 的票 GET 200、POST 403；上傳主系統照 400 `wrong_type`；上傳壞簽章 400 `bad_signature`；上傳別台的照 400 `mismatch` 且回 `bound_fingerprint_prefix`；上傳對的照 200 且 GET 變 valid；65 KB body 413；`_SPA_PAGES` 含 license（`GET /console/license` 回 SPA 殼 200）；守衛三支綠。本機 `curl -k --http1.1` 走一遍（本機管理員登入拿 cookie）"),

dict(id="P2-3", stage=1, repo="fe", model="opus", effort="medium",
  title="主控台「授權」分頁畫面：五種狀態、機器碼複製、拖拉上傳照、總覽頁頂端提醒",
  ren="new：`console/views/License.vue`（照雛形 `prototype/agent-console-license.html` 場景 1～6 的結構，**用 Keys.vue 的 dark-card／欄位元件，不自訂字級字型、不用左側粗邊框**）：狀態 Tag（尚未開通 danger／已授權 ok／到期提醒 warn／寬限期 danger／已過期 danger）、四步驟說明（只在尚未開通／已過期時顯示）、機器碼 `<code>` 全碼＋「複製」鈕（用 `navigator.clipboard` 要 fallback——主控台常是 http＋IP，`clipboard` 在非 secure context 是 undefined，照 memory 用 `document.execCommand('copy')` 退路）、照資訊 kv、拖拉上傳區（接受 `.license`，`FileReader` 讀文字 POST）、拒收原因紅框（wrong_type／bad_signature／mismatch＋綁定前綴／expired／malformed 各一句 i18n）；`console/licenseApi.js`（照 `keysApi.js` 兩支）；`console/licenseI18n.js`＋`src/config/locales/i18n/zh-tw/agent-console-license.json`、`en/agent-console-license.json`。extend：`console/router.js` PAGES 加 `{ name: 'license', group: 'ops', icon: 'pi pi-id-card', view: 'License', scope: null, dimOn: [] }`（任何連線狀態都開——沒照時 `NET` 會是 unsynced）；`console/views/Overview.vue` 頂端照 `overview.license` 顯示提醒列（notice 黃、grace／expired 紅，一句＋「前往授權」連結）；`console/Shell.vue` 不動（側欄讀 PAGES）。`npm run build:CONSOLE`。",
  edit=["console/router.js（PAGES 加一項）", "console/views/Overview.vue（頂端提醒列）"],
  new=["console/views/License.vue", "console/licenseApi.js", "console/licenseI18n.js", "src/config/locales/i18n/zh-tw/agent-console-license.json", "src/config/locales/i18n/en/agent-console-license.json"],
  deps=[], why="一頁五態＋上傳＋複製 fallback；照雛形刻但要守設計系統",
  accept="`npm run build:CONSOLE` 綠、`npm run lint` 無新 error、`grep -rn border-left console/views/License.vue` 為 0；對照雛形六場景：mock API 回五種 status 各截一張（headless Playwright 或 vitest 快照，不開視窗）；http＋IP 環境下「複製」鈕有效（fallback 路徑有測）；上傳假檔後顯示後端回的 reason 句子；總覽頁 notice／grace 各顯示一列。主控台靜態檔換法見 FR-166.1 STATE §4（本機 `~/guidant-agent-local`），P2-1／P2-2 合回後對本機 Agent 實走：無照 → 看到機器碼 → 用 LC CLI 簽綁本機的照 → 拖上去 → 變已授權、側欄其他分頁從反灰恢復"),
]


def check():
    ids = [c["id"] for c in CARDS]
    assert len(ids) == len(set(ids))
    stage_of = {c["id"]: c["stage"] for c in CARDS}
    for c in CARDS:
        for d in c["deps"]:
            assert d in ids and stage_of[d] < c["stage"], (c["id"], d)
    seen = {}
    for c in CARDS:
        for f in c["edit"]:
            key = (c["stage"], c["repo"], f.split("（")[0].split("(")[0].strip())
            assert key not in seen, (key, seen.get(key), c["id"])
            seen[key] = c["id"]
    print("OK", len(CARDS), "cards;", {s: [c["id"] for c in CARDS if c["stage"] == s] for s in sorted(set(stage_of.values()))})


if __name__ == "__main__":
    check()
