"""FR-182 P1 拆卡底表（license_center repo）：design.md §6、Notion spec、工作坊四欄都從這張表產。

批（Stage）＝能同時跑的那一組：不改同一檔、不依賴對方產出。
登記型檔：`web/app.py`（掛 blueprint）由 L1 當 owner 一次掛好兩個空 blueprint；`base.html` 導覽列只 L2 碰；
`web/api_schemas.py`／`web/csrf.py` 只 L3 碰；`cli/main.py` 只 L1 碰。
repo 全部 license-center。
"""

REPO = "license-center"

CARDS = [
dict(id="L1", stage=1, model="opus", effort="high",
  title="Agent 照地基：新模組 agent_licensing（表、簽發／開通／換綁／補發／展延）、序號與解鎖抽共用、CLI 五支、空 blueprint 先掛",
  ren="extend：序號三支（`ACTIVATION_CODE_TTL`／`new_activation_code`／`is_activation_code_expired`）從 `modules/licensing/service.py` 抽到 `core/activation_code.py`，licensing 改 import（行為不變，既有測試要綠）；`web/views/issuance.py` 的 `_resolve_passphrase`／`_unlock_or_flash_redirect` 搬到 `web/unlock_helper.py`，issuance 改 import；`core/crypto/engine.py` 加 `PAYLOAD_TYPE_AGENT_LICENSE = \"agent_license\"`；`core/status.py` 加 `compute_agent_status(expires_at, notify_days_before, grace_days)`（四態 valid／notice／grace／expired，不改 `compute_status`）；`core/schema.py` 加 `AgentLicense` dataclass（與 `License` 並列不繼承）。new：`modules/agent_licensing/{models,repository,service}.py`——`AgentLicenseIssuance` 表（欄位見 design §4.3）、repo（insert／get_by_id／get_by_activation_code／list_current／list_by_batch／mark_activated／mark_rebind_invalidated／count_by_customer）、service 六支（`issue_agent_licenses(count)`／`activate_agent_license`／`rebind_agent_license`／`reissue_agent_license`／`extend_agent_license`／`verify`），形狀照 licensing；Alembic revision；`migrations/env.py` 加 import；`cli/agent_commands.py` 五支（agent-issue／agent-activate／agent-verify／agent-extend／agent-rebind），`cli/main.py` 一行 `cli.add_command`。登記型 owner：建空殼 `web/views/agent_licenses.py`（只有 `bp = Blueprint`）與 `web/views/agent_activation_api.py`、`web/views/internal_agent_api.py`（空 Blueprint＋空 schema 不掛路由），`web/app.py` 三行掛上（機器兩支走 `api.register_blueprint`＋`exempt_machine_apis` 加進去）——L2／L3 只填檔不碰 `app.py`。DEV 套 migration（`.env` 指 `license_center_dev`）",
  edit=["src/license_center/core/crypto/engine.py", "src/license_center/core/schema.py", "src/license_center/core/status.py", "src/license_center/modules/licensing/service.py（改 import 序號三支）", "src/license_center/web/views/issuance.py（改 import 解鎖兩支）", "src/license_center/web/views/activation.py（改 import is_activation_code_expired）", "src/license_center/cli/main.py（add_command 一行）", "src/license_center/web/app.py（掛三個 blueprint）", "src/license_center/web/csrf.py（exempt 加兩個）", "migrations/env.py"],
  new=["src/license_center/core/activation_code.py", "src/license_center/web/unlock_helper.py", "src/license_center/modules/agent_licensing/__init__.py", "src/license_center/modules/agent_licensing/models.py", "src/license_center/modules/agent_licensing/repository.py", "src/license_center/modules/agent_licensing/service.py", "src/license_center/web/views/agent_licenses.py（空殼）", "src/license_center/web/views/agent_activation_api.py（空殼）", "src/license_center/web/views/internal_agent_api.py（空殼）", "src/license_center/cli/agent_commands.py", "migrations/versions/<rev>_agent_licensing_agent_license_issuance.py", "tests/test_agent_license_service.py", "tests/test_agent_status.py", "tests/test_activation_code_shared.py"],
  deps=[], why="整案核心：資料模型、六支 service、抽共用三處，一次看全才不會 L2／L3 各自補；既有測試全綠是抽共用的驗證",
  accept="`pytest` 全綠（含既有 31 支＋新三支）；`tests/test_architecture_boundaries.py` 自動涵蓋新模組仍綠；`alembic upgrade head` 在 DEV 建出 `agent_license_issuance`、`alembic check` 無漂移；CLI 走一輪：`agent-issue --count 3` 印 3 組照號＋序號 → `agent-activate <序號> --fingerprint <64 hex>` 輸出 `.license` → `agent-verify <檔>` 印 payload_type=agent_license、machine_fingerprint 相符、狀態 valid；拿主系統的 `.license` 餵 `agent-verify` 要拒（型別不符）；`flask --app license_center.web.app:create_app run` 起得來（空 blueprint 不炸）；`modules/licensing` 的 `grep -n activation_code` 只剩 import"),

dict(id="L2", stage=2, model="opus", effort="medium",
  title="LC 後台「Agent 授權」五頁：清單、簽發（數量）、簽發結果（一批）、離線開通、詳情＋客戶時間軸加一張卡",
  ren="new：填 L1 建的空殼 `web/views/agent_licenses.py`——`/agent-licenses/`（清單：搜尋客戶代碼／名稱／訂單、篩狀態／開通／型態，列尾下拉詳情／下載／展延／補發／換綁，只列現行照）、`/agent-licenses/issue`（表單：客戶代碼、客戶名稱、訂單、上層主系統照下拉（呼叫 `modules/licensing/service` 查該客戶現行照——鐵律③只叫 service）、型態、天數、**數量**、提醒天數、寬限天數、發照人）、`/agent-licenses/batch/<batch_id>`（這批 N 張：照號＋序號＋下載未綁機照；zip 全部下載）、`/agent-licenses/activate`（序號＋機器碼＋發照人）、`/agent-licenses/activate/<id>/result`、`/agent-licenses/<id>`（明文、事件歷史、操作下拉；非現行照標「已被取代」不給下載）、`/agent-licenses/<id>/{download,extend,reissue,rebind}`。五支模板照 `issue_form.html`／`activate_form.html`／`overview.html`／`license_detail` 的樣子。extend：`base.html` 導覽列加「Agent 授權」；`customer_timeline.py`＋模板加「Agent 照 N 台（已開通 x／待開通 y）→ 看清單」一張卡＋事件混進時間軸前綴「Agent」。解鎖走 `web/unlock_helper.py`。log：簽發／開通／換綁／補發／展延／下載六種 INFO 帶 `agent_license_id`、`customer_code`、actor；序號只記摘要（`activation_code_digest`）",
  edit=["src/license_center/web/views/agent_licenses.py（填空殼）", "src/license_center/web/templates/base.html", "src/license_center/web/views/customer_timeline.py", "src/license_center/web/templates/customer_timeline.html"],
  new=["src/license_center/web/templates/agent_licenses_index.html", "src/license_center/web/templates/agent_license_issue_form.html", "src/license_center/web/templates/agent_license_batch.html", "src/license_center/web/templates/agent_license_activate_form.html", "src/license_center/web/templates/agent_license_detail.html", "tests/test_agent_license_views.py"],
  deps=["L1"], why="五頁照既有樣板刻，但表單欄位、下拉查詢、下載守門、log 規範要逐一對",
  accept="對照雛形 `docs/features/FR-182-2611-agent-license/prototype/lc-agent-license.html` 七個場景逐頁核：登入後台 → 導覽「Agent 授權」→ 簽發（數量 3）→ 結果頁 3 組序號＋zip → 離線開通（序號＋64 碼）→ 結果頁下載 `.license` → CLI `agent-verify` 過 → 詳情頁事件歷史兩筆（簽發、開通）→ 清單該列「已開通」、另兩列「待開通（序號 N 天後逾期）」→ 客戶時間軸看到「Agent 照 3 台」；`tests/test_security_headers.py` 掃模板綠（inline script 帶 nonce、無 onclick）；`tests/test_csrf.py` 既有綠；`journalctl`／stderr 看到六種事件各一條且序號只有摘要；`grep -rn border-left templates/` 為 0"),

dict(id="L3", stage=2, model="opus", effort="medium",
  title="LC 機器 API 三支：Agent 線上開通（序號＋機器碼、IP 鎖定）、內部簽發 Agent 照、內部查客戶 Agent 照清單；合約測試",
  ren="new：填 L1 建的空殼——`web/views/agent_activation_api.py` `POST /api/activation/agent/activate`（無 token；body `activation_code`＋`machine_fingerprint` strip 後必填；鎖定走 `modules/auth/service.check_activation_lockout`／`record_activation_failure` 同一張 `activation_attempts` 表、同 `_lockout_key`；驗證順序比照既有：格式 → 鎖定 → 查無 → 已使用 → 序號逾期 → 授權過期 → passphrase；成功 `{\"license\": {…}, \"license_id\": \"alic-…\"}`，失敗 `{\"error\": \"…\"}` 字串比照既有六句）；`web/views/internal_agent_api.py` `POST /api/internal/agent/issue`（`X-API-Token`，body `customer_code`／`order_no`／`issued_to`／`parent_license_id?`／`license_type?`／`days?`／`count?`（預設 1，上限 50）／`notify_days_before?`／`grace_days?`；回 `{\"batch_id\", \"licenses\": [{\"license_id\", \"activation_code\", \"license\": {…}}]}`）與 `GET /api/internal/agent/licenses?customer_code=`（回現行照清單含狀態、到期、機器碼前 12 碼、開通狀態）；token 驗證與 `_authenticate`／`_require_valid_token` **從 `internal_api.py` 抽到 `web/internal_auth.py`** 兩邊共用（internal_api 已 492 行，不往裡加）。extend：`web/api_schemas.py` 加三個 schema（只驗格式）、`@bp.doc` 進 OpenAPI；`tests/test_api_contract.py` 加 Agent 三支的情境表（成功／401／400 缺欄／404 查無／409 已用／400 序號逾期／429 鎖定／503）；`tests/test_csrf.py` 加「請求真的走進 view」斷言；`docs/api-contract-baseline.md` 加三節",
  edit=["src/license_center/web/views/agent_activation_api.py（填空殼）", "src/license_center/web/views/internal_agent_api.py（填空殼）", "src/license_center/web/views/internal_api.py（抽 token 驗證到 internal_auth.py，改 import）", "src/license_center/web/api_schemas.py", "tests/test_api_contract.py", "tests/test_csrf.py", "docs/api-contract-baseline.md"],
  new=["src/license_center/web/internal_auth.py", "tests/test_agent_activation_api.py"],
  deps=["L1"], why="合約一定下來主系統就要對接，錯誤字串、順序、形狀要跟既有逐字一致；鎖定共用表要確認 key 不互相干擾",
  accept="`pytest tests/test_api_contract.py` 既有四支情境零變動、新三支全綠；`tests/test_csrf.py` 新斷言：POST 不帶 csrf token 仍走進 view（回 400 是參數錯不是 CSRF）；手打：`curl -XPOST :5062/api/activation/agent/activate -d '{\"activation_code\":\"<L1 簽的>\",\"machine_fingerprint\":\"<64 hex>\"}'` 回 200 帶信封、`agent-verify` 過；同序號再打 409；錯序號連打 6 次第 6 次 429，且**主系統那支** `/api/activation/activate` 同 IP 也被鎖（共用表，預期行為，寫進合約文件）；`curl -H 'X-API-Token: <dev token>' -XPOST /api/internal/agent/issue -d '{…,\"count\":2}'` 回 2 張；無 token 401 先於 body 錯；`/api/docs` 看得到三支"),

dict(id="L4", stage=3, model="sonnet", effort="medium",
  title="文件與收口：文件站四頁加 Agent 照、CLAUDE.md 介面表、DEV 端到端走一輪留證據",
  ren="extend：`docs/handbook/data-model.md` 加 `agent_license_issuance` 一節（欄位、與 `license_issuance` 的差異、為什麼分表）；`license-format.md` 加「Agent 照」一節（payload 欄位表、`payload_type` 判別、兩段式到期政策、與主系統照共用信封）；`operations.md` 加查案食譜（「客戶說 Agent 開通失敗」→ grep `agent_license_id`／序號摘要；「序號逾期」→ 補發序號）；`architecture.md` 模組表加 `agent_licensing`；`CLAUDE.md` 「與主產品的介面」表加三列、模組樹加一行、常用指令加五支 CLI；`docs/api-contract-baseline.md` 已由 L3 加，本卡只核。new：`docs/features/FR-182-2611-agent-license/evidence/p1-e2e.md`（在 BE repo）——DEV 從後台簽 3 張 → 離線開通 1 張 → 下載 → CLI 驗 → 內部 API 簽 2 張 → 線上開通端點開通 1 張 → 清單截圖描述，每步指令與回應貼上。`mkdocs build --strict` 綠",
  edit=["docs/handbook/data-model.md", "docs/handbook/license-format.md", "docs/handbook/operations.md", "docs/handbook/architecture.md", "CLAUDE.md"],
  new=["（BE repo）docs/features/FR-182-2611-agent-license/evidence/p1-e2e.md"],
  deps=["L2", "L3"], why="照現況寫、照清單走；只寫現況不寫沿革",
  accept="`poetry run mkdocs build --strict` 零警告；`grep -n \"原本\\|改判\\|第 [0-9] 棒\\|這次整理\" docs/handbook/*.md` 零命中；證據檔每一步有指令＋回應；`CLAUDE.md` 介面表三列與 L3 實際路徑逐字相同"),
]


def check():
    ids = [c["id"] for c in CARDS]
    assert len(ids) == len(set(ids)), "id 重複"
    stage_of = {c["id"]: c["stage"] for c in CARDS}
    for c in CARDS:
        assert all(d in ids for d in c["deps"]), c["id"]
        for d in c["deps"]:
            assert stage_of[d] < c["stage"], (c["id"], d)
    seen = {}
    for c in CARDS:
        for f in c["edit"]:
            key = (c["stage"], f.split("（")[0].split("(")[0].strip())
            assert key not in seen, (key, seen.get(key), c["id"])
            seen[key] = c["id"]
    by_stage = {}
    for c in CARDS:
        by_stage.setdefault(c["stage"], []).append(c["id"])
    print("OK", len(CARDS), "cards;", {k: v for k, v in sorted(by_stage.items())})


if __name__ == "__main__":
    check()
