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

每張卡：id／主題／批／repo／標題／reuse-extend-new／改哪些檔／新開哪些檔／依賴／驗收／model／effort／理由。
批（Stage）＝能同時跑的那一組：不改同一檔、不依賴對方產出、不搶同一外部資源。
B-4（CM-3619）由工作坊首腦驗 A-4 時補開（A-4 列的 `agent_credential_client.py` 實在主系統）；B-5（CM-3631）驗 B-3 時補開（連線欄值是套件算的，FE 改不到）；C-2（CM-3632）從 C-1 拆出、暫緩（決策者裁文件等真機驗收過後寫）；B-6a～d 首腦驗收抓到「對帳」仍直推後補開（批 8～10）。\n無人值守：全部判據機械化（P-0 判據寫死、E-1／F-3 讀證據檔、單向網路用本機 Agent 假位址模擬、A-4 版號由 B-1 開工釘），一路跑到 C-1 不停。
登記型檔各只讓一張卡碰：契約 `commands.py`／`__init__.py`（F-1）；套件 `routing.py`（F-3）；
Agent `registry.py`／`data_plane_server.py`（F-2）；主系統 `remote_agent.py` plugin 掛點（A-2）、
`main.py`（E-1）、`scripts/sql/` migration（E-1）；jedi-detection 三支 client（A-4）。
repo 值：main（BE）、fe、jedi-packages、agent。
"""

TOPIC_NAME = {
    "T0": "批 0：gunicorn 併發驗證（走長輪詢還是定時輪詢的判據）",
    "T1": "地基：契約、長輪詢端點、Agent 長輪詢元件",
    "T2": "指令六種改走長輪詢",
    "T3": "同步問答四種改走等回答插槽",
    "T4": "直推關閉、文件",
}
REPO_NAME = {"main": "BE", "fe": "FE", "jedi-packages": "jedi 套件", "agent": "evidence-agent"}

CARDS = [
# ───────── 批 0：先驗 gunicorn 吊請求撐不撐得住，結果決定 D2 ─────────
dict(id="P-0", topic="T0", stage=1, repo="main", model="opus", effort="medium",
  title="gunicorn 長輪詢併發驗證：sync 重現卡死、gthread＋配額證明一般 API 不受影響",
  ren="new：`scripts/dev/longpoll_bench.py`（純驗證腳本，不進產品）——開 N 條去打一支吊 30 秒的假端點、同時用 20 並發打 `GET /api/1.0/health` 量 p50／p95；假端點掛在 `core/plugins/_bench_longpoll.py` 只在 `DEBUG` 或 `BENCH_LONGPOLL=1` 時掛。extend：`main.py` gunicorn options 加 `worker_class`／`threads` 兩個 env（`GUNICORN_WORKER_CLASS` 預設 `sync` 不變、`GUNICORN_THREADS` 預設 16）——本卡只加設定入口不改預設，E-1 才翻預設。禁：不動 installer、不動 compose",
  edit=["main.py 280（gunicorn options 加 worker_class／threads）"],
  new=["scripts/dev/longpoll_bench.py", "core/plugins/_bench_longpoll.py", "docs/features/FR-181-2611-agent-outbound-only-channel/evidence/bench-longpoll.md"],
  deps=[], why="結果是 D2 的判據，數字要可信、要能說明為什麼；既有測試在 gthread 下跑一輪要看懂紅在哪",
  accept="DEV 以 `USE_GUNICORN=true GUNICORN_WORKERS=2 DEBUG=false` 起：①`sync`：開 2 條吊著 → health p95 > 25 秒（重現卡死，寫進證據）；②`gthread` 16 執行緒：開 20 條吊著 → health p95 與 0 條時差 < 50 ms；③`gthread` 下 `pytest test/` 紅的只能是基準就紅的（先跑 sync 留基準）；④`DEBUG=true` dev server 不受影響。證據檔寫三組數字＋結論——**判據寫死、不等人裁**：③的 health p95 比①慢 < 100 ms **且** gthread 下 pytest 沒有基準以外的新紅 → 結論「走長輪詢」；任一不成立 → 「退定時輪詢」。E-1／F-3 開工第一步讀這份證據檔照結論做"),

# ───────── 批 2：地基三張，互不依賴（契約是登記型檔由 F-1 一次定完；F-2／F-3 先用本機 path 指 F-1 工作區）─────────
dict(id="F-1", topic="T1", stage=1, repo="jedi-packages", model="opus", effort="medium",
  title="契約 jedi-agent-protocol 0.3.0：長輪詢取指令與回答的形狀，直推路徑常數改開關語意",
  ren="extend：`commands.py` 加 `COMMANDS_PATH=\"/agents/commands\"`、`REPLIES_PATH=\"/agents/replies/{request_id}\"`、`FILES_PATH=\"/agents/files/{request_id}\"`、`WAIT_PARAM`、`COMMANDS_LIMIT=20`；加 `Command` 信封 dataclass（`kind`／`request_id`／`task_uid`／`payload`／`expires_at`）與 `CommandsResponse`（`commands: list`／`server_time_utc`）；加 `Reply` dataclass（`status: taken|done|failed`／`result`／`reason`）；加 `KIND_*` 十三個常數（task／restart／update／config_changed／credentials_renew／detection_cancel／probe／credentials_set／fetch_file／send_file／send_pdf／delete_file／query_logs）＋ `REPLY_KINDS`（要回答的那七個）。既有直推路徑常數（`TASKS`／`RESTART`／…）與 body dataclass **不刪**（D6：直推留著用開關關），檔頭註解改成「直推預設關，長輪詢是主路徑」。`__init__.py` 匯出。**登記型檔 owner**：後面所有卡只 import 不改。發版 0.3.0 推 Nexus",
  edit=["jedi-agent-protocol/jedi_agent_protocol/commands.py", "jedi-agent-protocol/jedi_agent_protocol/__init__.py", "jedi-agent-protocol/pyproject.toml（0.3.0）"],
  new=["jedi-agent-protocol/tests/test_longpoll_contract.py"],
  deps=[], why="形狀一定就是後面十張卡的契約，改一次要想全；但不複雜",
  accept="pytest：十三個 `KIND_*` 不重複、`REPLY_KINDS` ⊂ 全部 kind、`Command.from_dict` 缺 `kind` 拋 ValueError、`CommandsResponse` 往返相等；`poetry publish -r nexus` 0.3.0 成功，回寫版號"),
dict(id="F-2", topic="T1", stage=1, repo="agent", model="opus", effort="high",
  title="Agent 長輪詢元件 command_pull：常駐一條 GET /agents/commands、分派表、退避、回覆進 outbox",
  ren="new：`guidant_agent/sync/app/command_pull.py`（照 `config_pull.py` 的 `WorkerComponent` 骨架）——註冊成功後起背景執行緒無限迴圈 `GET /agents/commands?wait=30`（通行證＋DPoP 同心跳）；204 立刻再掛、2xx 逐條分派、連線錯退避 5→10→…→60 秒；分派表是模組內一張 `dict[kind -> handler]`，本卡先接四個既有函式：`task`→`cmd_tasks` 的 `_parse`＋`task_queue.submit`（把 `_parse` 從 route 檔抽到 app 層 `task_intake.py`，route 改呼叫它）、`restart`→`ops.request_restart(mode, REASON_CLOUD, ACTOR_MAIN)`、`update`→`update_sync.trigger`、`config_changed`→`config_pull.request()`；其他九個 kind 先登記 no-op（A-3 填）。new：`guidant_agent/sync/app/reply_sender.py`——`reply(request_id, status, result=None, reason=None, file=None)` 打 `POST /agents/replies/<id>`，送不到走 `event_sender` 同一條 outbox（新 kind `reply`）。extend：`registry.py` 登記本元件（**登記型檔，本卡 owner，一次把 A-3 要的 handler 槽也留好**）；`data_plane_server.register_command_blueprints` 加開關 `cfg.data_plane_commands_enabled`（預設 False）才掛六個 `cmd_*`（D7，**本卡一併做**，避免 C-2 再碰登記型檔）。🔴 升級中／重啟已排定拿到 `task` → 不進佇列、回 `failed reason=upgrading`（現況 409 語意搬過來）",
  edit=["guidant_agent/platform/registry.py", "guidant_agent/sync/api/data_plane_server.py 61（register_command_blueprints 加開關）", "guidant_agent/sync/api/cmd_tasks.py（_parse 抽到 app 層）", "guidant_agent/sync/app/outbox.py（加 kind reply）", "config/config.py（AgentAuthConfig 加 data_plane_commands_enabled 預設 False）"],
  new=["guidant_agent/sync/app/command_pull.py", "guidant_agent/sync/app/task_intake.py", "guidant_agent/sync/app/reply_sender.py", "tests/sync/test_command_pull.py"],
  deps=[], why="Agent 側核心；退避、重掛、升級中拒收、outbox 順序四件事互相牽扯，要一次看全",
  accept="pytest：假主系統回 204 → 立刻再掛（兩次請求間隔 < 100 ms）；回一條 `task` → `task_queue.submit` 被叫一次且 ack 走既有路徑；回一條未知 kind → log warning、其餘照分派；連線錯三次 → 間隔 5／10／20 秒；升級旗標起時拿到 `task` → reply `failed upgrading`、佇列不變；`data_plane_commands_enabled=False` 時 8443 上 `POST /tasks` 回 404。本機對 DEV 主系統（F-3 工作區起的）實跑：主系統 DB 插一張 pending 單 → Agent 3 秒內 ack"),
dict(id="F-3", topic="T1", stage=2, repo="jedi-packages", model="opus", effort="high",
  title="jedi-remote-agent：GET /agents/commands 長輪詢端點、POST /agents/replies、GET /agents/files，喚醒走注入 port",
  ren="**開工第一步讀 P-0 證據檔 `evidence/bench-longpoll.md` 的結論**：「走長輪詢」→照本卡做；「退定時輪詢」→端點忽略 `wait`、查完立刻回（204 或 200），`waiter` port 仍定義但端點不呼叫 `wait`，其餘一字不差。new：`jedi_remote_agent/api/routes/agent_commands_route.py`——`AgentCommandsRoute.get`：守門同 `AgentHeartbeatRoute`（`AUTH_AGENT`）；組裝「這台現在該做的事」：pending 且 `_due` 的工單（搬 `AgentCommandService.redispatch_pending` 的查單邏輯，推送部分不搬）、`restart_requested` 有值→`restart`、`update_for_agent` 有值→`update`（這兩個由宿主透過 `command_sources` port 注入 callable，套件不認識 rollout）、宿主注入的 `agent_requests` 未過期 waiting 列→各 kind；有東西立刻回、沒有就呼叫注入的 `waiter.wait(agent_uid, timeout=wait)`（port，宿主用 Redis 實作；套件內建一個 `threading.Event` 版給測試）後再查一次；一次最多 20 條；**同一台第二條進來就讓第一條回 204**（D2 ③，`waiter.preempt(agent_uid)`）。`AgentRepliesRoute.post`：`status taken|done|failed`、JSON 或 multipart（檔案落宿主注入的 `spool.save(request_id, stream)`）、呼叫注入的 `on_reply(request_id, status, result)`。`AgentFilesRoute.get`：宿主注入的 `spool.open(request_id)` 串出去。extend：`routing.py` 一次掛三條（**登記型檔 owner**）；`remote_agent_service` 加 `mark_restart_dispatched(uid)`（Agent 拿到 `restart` 就清旗標，不然每條長輪詢都回 restart）。禁：套件內不 import redis、不建表——`agent_requests` 的讀寫全走 port",
  edit=["jedi-remote-agent/jedi_remote_agent/api/routing.py", "jedi-remote-agent/jedi_remote_agent/app/service/remote_agent_service.py", "jedi-remote-agent/jedi_remote_agent/app/service/agent_task_service.py（list_pending_for_agent 加 due 過濾）", "jedi-remote-agent/pyproject.toml（1.13.0）"],
  new=["jedi-remote-agent/jedi_remote_agent/api/routes/agent_commands_route.py", "jedi-remote-agent/jedi_remote_agent/app/ports/command_channel.py（waiter／spool／command_sources／on_reply 四個 port 的 Protocol）", "jedi-remote-agent/tests/test_agent_commands_route.py"],
  deps=[], why="端點是整案的匯流點，port 切得不對主系統就得 import 套件內部；吊住與搶佔的邏輯要在多執行緒下正確",
  accept="pytest（Event 版 waiter）：無待辦 `wait=1` → 1 秒後 204；吊著時 `waiter.notify(uid)` → 100 ms 內回 200 帶 commands；同台第二條進來 → 第一條 204、第二條吊著；pending 單 + restart + 一筆 waiting request → 回三條且 kind 正確；過期 request 不回；`replies` multipart → `spool.save` 被叫；`routing.py` 三條都在 `AUTH_AGENT` 清單。發版 1.13.0 推 Nexus，回寫版號與 port 介面"),

# ───────── 批 3：主系統接上套件（環境卡先）＋等回答插槽 ─────────
dict(id="E-1", topic="T1", stage=2, repo="main", model="sonnet", effort="medium",
  title="環境卡：主系統釘 jedi-agent-protocol 0.3.0／jedi-remote-agent 1.13.0、gunicorn 預設改 gthread、agent_requests 表 migration",
  ren="extend：`pyproject.toml` 釘兩支新版（Nexus 已有，不走 path）、`poetry update jedi-agent-protocol jedi-remote-agent`；`main.py` `GUNICORN_WORKER_CLASS` 預設翻成 `gthread`、`GUNICORN_THREADS` 預設 16（P-0 加的入口，本卡翻預設；P-0 結論若是退定時輪詢則本卡不翻、回寫說明）；`install.sh` 寫 `.env` 時補這兩個變數（給客戶調）。new：migration `scripts/sql/2026-11-XX-fr181-agent-requests.sql`——表 `compliance.agent_requests`（`id`／`uid`／`agent_id` FK／`tenant_id`／`kind`／`payload` jsonb／`status` waiting|taken|done|failed|expired／`result` jsonb／`reason`／`expires_at`／`taken_at`／`done_at`／created 系列；索引 `(agent_id, status, expires_at)`；GRANT cm_app；RLS 照 `agent_tasks`；`INSERT schema_migrations`）；主線 `phase=active` → 回寫「出貨基線待重產」。禁：不寫任何 service／route",
  edit=["pyproject.toml", "poetry.lock", "main.py 280（預設翻 gthread）", "scripts/installer/install.sh 1490（.env 補兩個 gunicorn 變數）", "docs/claude/deployment-env.md"],
  new=["scripts/sql/2026-11-XX-fr181-agent-requests.sql"],
  deps=["F-1", "P-0"], why="機械性：釘版、翻預設、照先例寫表（jedi-remote-agent 1.13.0 由 F-3 同批發版，本卡釘不到就先釘契約、回寫說明，A-1 開工再釘）",
  accept="`python -c 'import jedi_agent_protocol; print(jedi_agent_protocol.__version__)'` 0.3.0、`jedi_remote_agent` 1.13.0；`USE_GUNICORN=true` 起 log 印 `worker_class=gthread threads=16`；DEV 套 migration 後 `\\d compliance.agent_requests` 欄位齊、`schema_migrations` 有列；`pytest test/test_module_boundaries.py` 綠；回寫「出貨基線待重產」"),
dict(id="A-1", topic="T3", stage=3, repo="main", model="opus", effort="high",
  title="等回答插槽 agent_request_service：ask／fire／reply、Redis 喚醒跨 worker、配額、過期 reaper，實作套件四個 port",
  ren="new：`app/remote_agent/service/agent_request_service.py`——`ask(agent, kind, payload, timeout=30)`：寫 `agent_requests`（`expires_at`＝now+5 分，D3）、`publish agent:<uid>`、`subscribe reply:<request_id>` 吊著等（配額：同時等回答 ≤ 8 條，滿了直接 503 `GRC_503_AGENT_BUSY`）、喚醒後讀 DB result 回、逾時 504 `GRC_504_AGENT_NO_REPLY` 並留 waiting；`fire(agent, kind, payload)`：寫表＋publish 不等；`reply(request_id, status, result)`：落 DB＋`publish reply:<id>`；`taken` 只改狀態不喚醒。new：`infra/remote_agent/redis_command_waiter.py` 實作 F-3 的 `waiter` port（`wait` 用 Redis pub/sub 加 `threading.Event` 本地鏡像、`notify`、`preempt`，長輪詢配額 ≤ 8 條超過回 204）；`infra/remote_agent/file_spool.py` 實作 `spool`（`/tmp/agent-outbound/<request_id>`、取走即刪、`reap_orphans(1h)`）；`agent_requests` repo（domain entity／query entity／repo impl 照 `agent_tasks` 形狀）；`command_sources` 實作（restart／update 兩個 callable 包既有 `restart_requested` 與 `rollout_service().update_for_agent`）。extend：`di_containers/remote_agent/` 註冊；`core/plugins/remote_agent.py` mount 時把四個 port 注入套件（**本卡只注入，不動 `_agent_commands.py`**——那是 A-2）；背景排程加 `expire_requests`（每分鐘標 expired）與 `reap_orphans`。新 error code 兩個進 `common/code/grc_error_code.py`＋ FE `error-code.json` 同步",
  edit=["di_containers/remote_agent/remote_agent_containers.py", "core/plugins/remote_agent.py（mount 注入四 port）", "common/code/grc_error_code.py", "core/scheduler_agent.py（加 expire_requests 每分鐘、reap_orphans 每小時）"],
  new=["app/remote_agent/service/agent_request_service.py", "domain/remote_agent/entity/agent_request_entity.py", "domain/remote_agent/repository/agent_request_repo.py", "domain/remote_agent/service/agent_request_domain_service.py", "infra/remote_agent/model/agent_request.py", "infra/remote_agent/repository/agent_request_repo_impl.py", "infra/remote_agent/redis_command_waiter.py", "infra/remote_agent/file_spool.py", "test/test_agent_request_service.py"],
  deps=["E-1", "F-3"], why="整案最難的一張：跨 gunicorn worker 的喚醒、配額、逾時與 DB 狀態四者要一致；錯了是「瀏覽器永遠轉圈」這種難查的病",
  accept="pytest：`ask` 在另一執行緒 `reply done` 後 200 ms 內回 result；`reply` 晚於逾時 → `ask` 已 504、DB 仍落 done；第 9 條 `ask` → 503；過期 reaper 把 6 分鐘前的 waiting 標 expired。**gunicorn 2 worker 實打**：兩個終端各打一個 `ask`（假 kind）、用 `redis-cli PUBLISH` 模擬回覆 → 兩條都在正確的 worker 醒來（log 印 pid）；`redis-cli` 殺掉 Redis → `ask` 30 秒後 504 不 500；`ls /tmp/agent-outbound` 取走後為空"),

# ───────── 批 5：指令與同步問答各自接上（A-2 主系統側、A-3 Agent 側、A-4 套件側，三個 repo 互不碰）─────────
dict(id="A-2", topic="T2", stage=4, repo="main", model="opus", effort="medium",
  title="指令六種改走長輪詢：_agent_commands 改成「寫正本＋喚醒」，取消與換證寫 agent_requests，直推加開關預設關",
  ren="extend：`core/plugins/_agent_commands.py`——`_push_after_commit`／`push_restart_after`／`push_update_soon`／`redispatch_on_heartbeat` 的「送」全部改成 `waiter.notify(agent_uid)`（正本已在 DB，長輪詢端點自己查）；`with_command_push` 的 `_PUSH_AFTER` 改成 notify；`record_probe_after` 刪（連線欄改二態）。`_agent_config_notify.notify_agent_after_commit` 改寫 `agent_requests(kind=config_changed)`＋notify（取代直推 `/config/notify`）。`_agent_keys.py:118` 立即換證改 `fire(agent, credentials_renew)`。`AgentCommandService`：整支保留，`send()` 開頭加 `if not settings.AGENT_DIRECT_PUSH_ENABLED: return CommandResult.disabled()`（D6 開關，預設 False，`config/config.py` 加變數）；`run_now`／`after_commit` 不動。`redispatch_on_heartbeat` 留一行 notify（心跳回來順手敲一下鈴，仍不夾帶）。`last_command_status` 停寫（欄位留，B-3 FE 改二態）",
  edit=["core/plugins/_agent_commands.py", "core/plugins/_agent_config_notify.py 135", "core/plugins/_agent_keys.py 118", "app/remote_agent/service/agent_command_service.py 55（開關）", "config/config.py（AGENT_DIRECT_PUSH_ENABLED）", "test/test_agent_command_service.py"],
  new=[],
  deps=["A-1"], why="改既有六條路徑的「送」那一段，每條要確認正本已寫、喚醒在 commit 之後；不難但要逐條核",
  accept="pytest 既有 `test_agent_command_service` 改成新語意綠；突變：拿掉 `AGENT_DIRECT_PUSH_ENABLED` 判斷 → 守衛紅。DEV＋本機 Agent（F-2 版，`AGENT_DATA_DIR=./data` 起、對 DEV 註冊；**模擬單向網路**：DEV DB 把該 Agent 列的 `base_url` 改成 `https://10.255.255.1:8443` 不存在的位址，直推必失敗）實測：派工 3 秒內開跑；管理頁按重啟 → Agent 重啟且旗標清掉、不重複重啟；分批升級轉成下發 → Agent 3 秒內開始下載；改授權清冊 → Agent 3 秒內拉到；按立即換證 → `agent_requests` 一列 done、Agent log 有 renew；`grep -rn 'data_plane_client().command\\|\\.send(' core/ app/` 只剩開關後那一處"),
dict(id="A-3", topic="T3", stage=4, repo="agent", model="opus", effort="high",
  title="Agent 回答類接線：probe／credentials_set／detection_cancel／credentials_renew／query_logs／證據檔四種，taken→done／failed",
  ren="extend：`command_pull.py` 分派表填九個 no-op——`credentials_renew`→`cert_renew.request_now()`；`detection_cancel`→`scan` 既有 cancel 的 app 層函式（從 `detection_route.DetectionCancelRoute` 抽到 `scan/app/cancel.py`，route 改呼叫）；`probe`→既有 probe app 函式（同法抽 `scan/app/probe.py`）；`credentials_set`→`scan/app/credential_store` 既有存法；`query_logs`→`platform/audit_log.list_logs`（`cmd_logs` 已是薄殼）；`fetch_file`→`GET /agents/files/<id>` 下載→`jedi_file_upload` `FileUploadService.save`→reply done 帶 `{file_uid, sha256}`；`send_file`／`send_pdf`→讀本機（pdf 先走既有轉檔）→`reply_sender.reply(file=)` multipart；`delete_file`→既有刪除、reply done。要回答的七個 kind **先 `taken` 再做**，做失敗 `failed reason=`；證據檔四種的 app 層從 `files/api/blob_route.py` 抽到 `files/app/blob_ops.py`，route 改薄殼。禁：不碰 `registry.py`／`data_plane_server.py`（F-2 已留槽）",
  edit=["guidant_agent/sync/app/command_pull.py（填分派表）", "guidant_agent/scan/api/routes/detection_route.py（抽 app 層）", "guidant_agent/scan/api/routes/credential_route.py", "guidant_agent/files/api/blob_route.py（抽 app 層）"],
  new=["guidant_agent/scan/app/cancel.py", "guidant_agent/scan/app/probe.py", "guidant_agent/files/app/blob_ops.py", "tests/sync/test_command_reply_kinds.py"],
  deps=["F-2"], why="九個 kind 各接一支既有函式，重點是「抽 app 層不複製」與回覆狀態機；量多但每個不深",
  accept="pytest：每個 kind 一支：收到→`taken` reply→handler 被叫→`done` reply 帶 result；handler 拋 → `failed` 帶 reason；`fetch_file` 下載 sha 不符 → failed 不落檔；route 殼（`POST /detection/cancel` 等）在開關開時仍能呼叫同一支 app 函式（抽層沒抽壞）。`grep -n 'def ' files/api/blob_route.py` 每支 ≤ 10 行"),
dict(id="A-4", topic="T3", stage=4, repo="jedi-packages", model="sonnet", effort="medium",
  title="jedi-detection 三支 agent client 改走注入的 ask／fire port，不再自己打 HTTP",
  ren="extend：`agent_probe_client.py`→`probe(agent, target)` 改 `self._channel.ask(agent, KIND_PROBE, {target})` 回 result；`agent_credential_client.py`→`ask(KIND_CREDENTIALS_SET, …)`（存好才算，要等）；`agent_cancel_client.py`→`fire(KIND_DETECTION_CANCEL, {job_uid})`。三支的建構子改收 `channel` port（`ask`／`fire` 兩個方法的 Protocol，放 `jedi_detection/app/ports/agent_channel.py`）；主系統 DI 注入 A-1 的 service。既有 HTTP 路徑**刪**（套件不留開關——主系統開關關閉時這三條本來就打不到，留兩套在套件裡沒意義；主系統側 `AgentCommandService` 留著已足夠 D6）。錯誤碼對映：`ask` 504→既有 `AGENT_UNREACHABLE`、503→新 `AGENT_BUSY`。發版",
  edit=["jedi-detection/jedi_detection/infra/detection_tools/connector/agent_probe_client.py", "jedi-detection/jedi_detection/infra/detection_tools/connector/agent_credential_client.py", "jedi-detection/jedi_detection/infra/detection_tools/connector/agent_cancel_client.py", "jedi-detection/jedi_detection/app/service/detection_dispatch_guard.py（probe 呼叫點）", "jedi-detection/pyproject.toml"],
  new=["jedi-detection/jedi_detection/app/ports/agent_channel.py", "jedi-detection/tests/test_agent_channel_clients.py"],
  deps=["A-1", "F-1"], why="三支 client 換底、形狀固定，機械性",
  accept="pytest：假 channel 回 `{reachable: true}` → probe 回 True；`ask` 拋 timeout → 既有 `AGENT_UNREACHABLE`；cancel 只叫 `fire` 不叫 `ask`；`grep -rn 'httpx\\|requests' connector/agent_*_client.py` 為 0。發版推 Nexus 回寫版號"),

# ───────── 批 6：證據檔、遠端查日誌、FE 連線欄（三個不同檔／repo）─────────
dict(id="B-1", topic="T3", stage=5, repo="main", model="opus", effort="medium",
  title="證據檔四操作改走等回答插槽：上傳暫存→Agent 取走、下載／轉 PDF→Agent 送上來、刪除 fire",
  ren="**開工第一步**：讀 A-4（CM-3604）回寫的 jedi-detection 版號，`pyproject.toml` 釘上、`poetry update jedi-detection`、確認 DI 已把 `agent_request_service` 注入三支 client（A-2 已接線）。extend：`infra/upload_file/remote_agent_adapter.py`——`save_file`：收檔→`spool.save`→`ask(fetch_file, {request_id, name, sha256})`→result 的 `file_uid` 寫參照 row；`get_file`／`fetch_agent_blob`：`ask(send_file, {file_uid})`→`spool.open(request_id)` 串給呼叫端→讀完刪；`convert_to_pdf`：`ask(send_pdf)`；`delete_file`：`fire(delete_file)`。`_via_data_plane`／`_data_plane`／`_lookup_agent` 刪（直推開關關閉時這條本來就不通；主系統側直推留在 `AgentCommandService` 已足夠 D6）。錯誤碼不變（FE 依碼顯示）：504→`FILE_AGENT_UNREACHABLE`、Agent `failed`→`FILE_AGENT_BAD_RESPONSE`。禁：`managed_file_upload_service.py` 的 adapter 選擇邏輯不動",
  edit=["infra/upload_file/remote_agent_adapter.py", "test/test_remote_agent_adapter.py", "pyproject.toml（釘 A-4 的 jedi-detection 版）", "poetry.lock"],
  new=[],
  deps=["A-1", "A-3"], why="四條路徑換底、串流與暫存刪除時機要對；大檔要實測",
  accept="DEV 一個租戶 `STORAGE_CONFIG` 設 remote_agent 指本機 Agent（同 A-2 的單向模擬）：上傳 1 MB → `upload_files` 參照 row 有 `save_file_name`、`/tmp/agent-outbound` 空、Agent 側檔存在；預覽／下載拿到同 sha；docx 轉 PDF 可開；刪除 → Agent 側檔消失；**100 MB 檔上傳與下載各量一次時間寫進回寫**；Agent 停掉 → 上傳 30 秒後回 `FILE_AGENT_UNREACHABLE` 不 500；jedi-detection 釘版 commit 在本卡"),
dict(id="B-2", topic="T3", stage=5, repo="main", model="sonnet", effort="low",
  title="支援頁遠端查 Agent 日誌改走 ask(query_logs)",
  ren="extend：FR-178 主系統側打 Agent `GET /logs` 的那支（`app/support/service/agent_log_query_app_service.py`）改 `ask(agent, KIND_QUERY_LOGS, filters)` 回 result，回應形狀不變（FE 不動）；逾時→既有「Agent 無回應」錯誤碼",
  edit=["app/support/service/agent_log_query_app_service.py", "test/test_agent_log_query_app_service.py"],
  new=[],
  deps=["A-1", "A-3"], why="單點換底",
  accept="支援頁「系統日誌查詢」來源選本機 Agent（同 A-2 單向模擬）→ 查得到；Agent 停掉 → 30 秒後頁面顯示無回應不白屏"),
dict(id="B-4", topic="T3", stage=5, repo="main", model="opus", effort="medium",
  title="Agent 工具帳密設定／清除改走等回答插槽 ask(credentials_set)",
  ren="extend：`infra/remote_agent/agent_credential_client.py` put／clear 改 `ask(agent, KIND_CREDENTIALS_SET, {tool, fields|null}, timeout=15)`；`core/plugins/_agent_tool_credentials.py` set／clear 拆成「短交易查 agent＋驗工具 → 交易外 ask → 短交易寫回 configured_tools＋audit」（A-1 規則：ask 不可在 @transaction 內）；錯誤碼對映不變（504／503→AGENT_OFFLINE、failed 欄位驗證→FIELDS_INVALID）；fingerprint_mismatch 拿掉。【補卡來源】工作坊首腦驗 A-4 時發現 `agent_credential_client.py` 在主系統不在 jedi-detection（首腦拆卡時位置寫錯），A-4 動不到，母卡無其他卡負責，故補開 CM-3619",
  edit=["infra/remote_agent/agent_credential_client.py", "core/plugins/_agent_tool_credentials.py", "test/（既有 agent_tool_credentials／agent_local_credentials 測試）"],
  new=[],
  deps=["A-1", "A-3"], why="拆交易與錯誤對映要對；與 B-1／B-2 不同檔可同批",
  accept="pytest：假 service ask 回 configured → set 寫回 configured_tools；ask 拋 504 → AGENT_OFFLINE；ask 在交易內被叫就 raise（突變：改回整支 @transaction → 紅）；clear 送 fields=null；`grep data_plane_client` 兩檔為 0。DEV＋A-3 版 Agent：管理頁設定假帳密 2 秒內顯示已設定、Agent 側帳密庫有；清除 → 消失"),
dict(id="B-5", topic="T2", stage=5, repo="jedi-packages", model="sonnet", effort="medium",
  title="jedi-remote-agent 連線欄算法改二態：connection_state 只看心跳，拿掉 one_way",
  ren="extend：`common/agent_auth/heartbeat.py` `connection_state()` 改只看心跳（在線 normal／不在線 offline）；`remote_agent_service.py` 呼叫點、serializer 註解跟著改；bump 1.17.1 發版。【補卡來源】工作坊首腦驗 B-3 時發現連線欄的值是套件算的，直推關掉後每台在線 Agent 都會被算成 one_way（語意錯），B-3 只改 FE 碰不到套件（首腦拆卡時漏套件那一半），故補開 CM-3631；主系統改釘 1.17.1 歸 C-1",
  edit=["jedi-remote-agent/jedi_remote_agent/common/agent_auth/heartbeat.py", "jedi-remote-agent/jedi_remote_agent/app/service/remote_agent_service.py", "jedi-remote-agent/jedi_remote_agent/api/serializers/remote_agent.py", "jedi-remote-agent/tests/test_offline_waiting_fields.py", "jedi-remote-agent/pyproject.toml（1.17.1）"],
  new=[],
  deps=[], why="單一函式換判據＋發版，機械性",
  accept="套件全套 tests 綠、突變（改回看 last_command → 紅）；`grep -rn CONNECTION_ONE_WAY jedi_remote_agent/` 只剩常數定義；Nexus 可解析 1.17.1；回寫「主系統要升 1.17.1（C-1 釘）」"),
dict(id="B-3", topic="T2", stage=5, repo="fe", model="sonnet", effort="low",
  title="Agent 管理頁「連線」欄三態改二態，刪「僅單向」與 hover 文案",
  ren="extend：`RemoteAgentManage.vue` 連線欄只看 `last_seen`（在線／離線），刪 `last_command_status` 分支與「僅單向：請確認 8443…」hover；`src/locales/*` 刪對應 key。禁：不動其他欄",
  edit=["src/views/remote-agent/RemoteAgentManage.vue", "src/locales/zh_Hant_TW/*.json", "src/locales/en/*.json"],
  new=[],
  deps=["A-2"], why="刪分支，機械性",
  accept="vitest 二態各一；`grep -rn 'last_command\\|8443' src/` 為 0；`vite build` 0 error；`grep border-left` 0"),

# ───────── 批 7：文件與守衛 ─────────
dict(id="B-6a", topic="T3", stage=8, repo="jedi-packages", model="sonnet", effort="low",
  title="契約加 KIND_VERIFY_FILE（對帳：Agent 重算證據檔 sha256）、發 0.5.2",
  ren="extend：`jedi-agent-protocol/commands.py` 加 `KIND_VERIFY_FILE = \"verify_file\"` 進 `ALL_KINDS` 與 `REPLY_KINDS`（要回答：result `{sha256}`；找不到回 failed reason=missing）；`__init__.py` 匯出；bump 0.5.2 發版。【補卡來源】首腦驗收抓到：Agent 管理頁「對帳」仍走直推 `GET /blob/<uid>/sha256`（§1.2 盤點時併進 blob 沒單獨列，B-1 只改四操作），開關關著對帳每檔回 error",
  edit=["jedi-agent-protocol/jedi_agent_protocol/commands.py", "jedi-agent-protocol/jedi_agent_protocol/__init__.py", "jedi-agent-protocol/pyproject.toml（0.5.2）", "jedi-agent-protocol/tests/test_longpoll_contract.py"],
  new=[],
  deps=[], why="加一個常數發版，機械性",
  accept="pytest：十四個 kind 不重複、`KIND_VERIFY_FILE in REPLY_KINDS`；`poetry publish -r nexus` 0.5.2；回寫「B-6b／B-6c／B-6d 要升 0.5.2」"),
dict(id="B-6b", topic="T3", stage=9, repo="agent", model="sonnet", effort="low",
  title="Agent 長輪詢接 verify_file：重算本機證據檔 sha256 回覆",
  ren="extend：`files/app/blob_ops.py` 加 `verify(uid) -> str`（讀 `open_blob(uid)` 算 sha256，照 `blob_route.py:49` 既有 `/blob/<uid>/sha256` 的算法抽成同一支，route 殼改呼叫它）；`files/api/commands.py` `HANDLERS` 加 `KIND_VERIFY_FILE: _verify`（照 `_send` 的 `_found` 形狀：找不到→failed reason=missing）。釘契約 0.5.2",
  edit=["guidant_agent/files/app/blob_ops.py", "guidant_agent/files/api/commands.py", "guidant_agent/files/api/blob_route.py（sha256 route 改呼叫 blob_ops.verify）", "pyproject.toml（契約 0.5.2）"],
  new=["test/files/test_verify_file_command.py"],
  deps=["B-6a"], why="一支函式＋一列 handler",
  accept="pytest：verify_file → taken → done 帶 `{sha256}`；uid 不存在 → failed reason=missing；`/blob/<uid>/sha256` route 與 handler 算出同值（抽層沒抽壞）"),
dict(id="B-6c", topic="T3", stage=9, repo="jedi-packages", model="sonnet", effort="medium",
  title="jedi-remote-agent reconcile 改走注入的 file_verifier port，不再直推 /blob/<uid>/sha256；發 1.17.2",
  ren="extend：`plugin/contract.py` `RemoteAgentAdapters` 加 `file_verifier: Any = None`（docstring：`(agent, file_uid) -> str|None`，None＝Agent 說沒有；拋例外＝連不到／逾時）；`remote_agent_service.py` `reconcile()` 274 行 `self._data_plane.command(... /sha256)` 改 `self._file_verifier(agent, r[\"save_file_name\"])`：None→missing、例外→error reason=unreachable、回值比對 anchor→matched／mismatched；未注入 port 時整批 error reason=no_verifier（不退回直推）。建構子加 `file_verifier=None`，assembly 從 adapters 接。釘契約 0.5.2、bump 1.17.2 發版",
  edit=["jedi-remote-agent/jedi_remote_agent/plugin/contract.py", "jedi-remote-agent/jedi_remote_agent/app/service/remote_agent_service.py", "jedi-remote-agent/jedi_remote_agent/app/assembly.py（接 port）", "jedi-remote-agent/pyproject.toml（1.17.2、契約 >=0.5.2）", "jedi-remote-agent/tests/test_reconcile.py（或既有對帳測試）"],
  new=[],
  deps=["B-6a"], why="換一個呼叫點＋加 port，規格固定",
  accept="pytest：假 verifier 回同 sha → matched；回不同 → mismatched；回 None → missing；拋 → error unreachable；未注入 → 全 error no_verifier；`grep -n '_data_plane.command' remote_agent_service.py` 對帳段為 0。發版回寫版號"),
dict(id="B-6d", topic="T3", stage=10, repo="main", model="sonnet", effort="low",
  title="主系統注入 file_verifier：對帳每檔 ask(verify_file)；釘契約 0.5.2／remote-agent 1.17.2",
  ren="extend：`core/plugins/_agent_channel.py` `channel_adapters()` 回傳 dict 加 `file_verifier=LazyFileVerifier()`——實作 `(agent, file_uid)`：`agent_request_service.ask(agent, KIND_VERIFY_FILE, {\"file_uid\": file_uid}, timeout=15)` 回 result[\"sha256\"]；`AgentRequestFailed` 且 reason=missing → 回 None；逾時／busy → 原樣拋給套件記 unreachable。🔴 `reconcile` 是逐檔同步 ask，N 檔＝N 次 ask 各佔等回答配額一條——改成每檔序列呼叫（不並發），百檔約 2 分鐘，回寫記實測時間；若要並發另開卡。釘 0.5.2／1.17.2、`poetry update` 兩支",
  edit=["core/plugins/_agent_channel.py", "pyproject.toml", "poetry.lock", "test/test_agent_outbound_only.py（對帳路徑加進「開關關不直推」五條變六條）"],
  new=[],
  deps=["B-6b", "B-6c"], why="注入一個 callable＋釘版",
  accept="DEV 本機 Agent（證據存 Agent 的租戶，`STORAGE_CONFIG=remote_agent` 指本機 Agent）上傳 3 檔 → 管理頁「對帳」→ `{total:3, matched:3}`；Agent 側手改一檔 bytes → mismatched 1；刪一檔 → missing 1；Agent 停掉 → error 3 reason unreachable、30 秒內回不 500；守衛六條綠"),
dict(id="C-1", topic="T4", stage=6, repo="main", model="sonnet", effort="medium",
  title="釘 jedi-remote-agent 1.17.1、守衛測試「直推開關預設關」",
  ren="**開工第一步**：釘 `jedi-remote-agent==1.17.1`（B-5 發的）、`poetry update jedi-remote-agent`。new：`test/test_agent_outbound_only.py`——①`AGENT_DIRECT_PUSH_ENABLED` 預設 False；②開關 False 時 `AgentDataPlaneClient.command` patch 成 raise 後跑派工／重啟／升級／換證／設定變更五條全綠；③`agent_requests` 預設 `expires_at` = +5 分。【範圍縮減】決策者 11-07 裁「文件不用現在寫，太早」：原本的 FR-170／171 指路行、手冊、資安報告、README 四項拆到 C-2（CM-3632，暫緩，真機驗收過後派）",
  edit=["pyproject.toml（釘 1.17.1）", "poetry.lock"],
  new=["test/test_agent_outbound_only.py"],
  deps=["A-2", "B-1", "B-2", "B-3", "B-5"], why="釘版＋守衛，照清單做",
  accept="守衛三支綠、突變①翻預設 True → 紅；`python -c 'import importlib.metadata as m; print(m.version(\"jedi-remote-agent\"))'` 1.17.1"),
dict(id="C-2", topic="T4", stage=7, repo="main", model="sonnet", effort="low",
  title="文件收口：FR-170／171 指路行、安裝手冊刪 8443、資安報告連線清單、README 結論（暫緩，真機驗收過後才派）",
  ren="extend：FR-170 design §3.x、FR-171 design 與 discussion 檔頭各加一行「本段指令直推已被 FR-181 推翻」（不改內文，D8）；`docs/user-manual/` 安裝段刪「8443 要對主系統開放」、加「Agent 只需能連到主系統」；`docs/security-report/connections/` 8443 條目改「預設不提供指令功能，只有健檢」、新增 `/agents/commands`／`replies`／`files` 三條出站；FR-181 README 結論與總進度對齊終局。【狀態暫緩】決策者 11-07 裁「文件不用現在寫，太早」——文件描述終局，等首腦在 123 擋 8443 做完真機驗收（design v1.1 §8）通過後改回 Not started 派出",
  edit=["docs/features/FR-170-2610-agent-architecture/design.md（檔頭一行）", "docs/features/FR-171-2610-agent-command-channel/design.md（檔頭一行）", "docs/features/FR-171-2610-agent-command-channel/discussion.md（檔頭一行）", "docs/user-manual/（安裝段）", "docs/security-report/connections/（8443 與三條新出站）", "docs/features/FR-181-2611-agent-outbound-only-channel/README.md"],
  new=[],
  deps=["C-1"], why="純文件對齊，照清單做",
  accept="`grep -rn '8443' docs/user-manual/` 只剩 Agent 主控台相關；FR-170／171 三處檔頭有指路行且 `git diff` 只有那一行；FR-181 站 `render_index.py` 重 build 無錯；沿革字眼 grep 0"),
]


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"], c["repo"], f.split()[0].split("（")[0].split("(")[0])
            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()
