diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 0522783a..b3cdd0f5 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,26 @@ +## 2026-06-24|Blocked products owner response intake preflight + +**背景**:blocked products 已有 owner decision packages `8/8`、response templates `8/8`、acceptance ledgers `8/8`,但仍缺真正 owner response。為避免下一輪人工判斷混亂,本輪新增可執行 intake preflight,讓未來收件先走機器檢查與 lanes 分流。 + +**新增 / 更新**: +- `scripts/security/blocked-products-owner-response-intake-preflight.py` +- `docs/operations/blocked-product-owner-responses/README.md` +- `docs/operations/codex-gitea-blocked-products-owner-response-intake-preflight.snapshot.json` +- `docs/operations/CODEX-GITEA-BLOCKED-PRODUCTS-OWNER-RESPONSE-INTAKE-PREFLIGHT-2026-06-24.md` + +**固定口徑**: +- blocked products:`8` +- response files:`0` +- intake lanes:`6` +- required owner response fields:`14` +- acceptance checks:`16` +- rejection guards:`15` +- owner response received / accepted / rejected:`0 / 0 / 0` +- review branch ready / remote dev ready / remote dev created:`0 / 0 / 0` +- product repo write / runtime write / secret collection:`0` + +**邊界**:這是 repo-only intake preflight,不是 owner response、review branch、remote `dev` branch、Gitea repo write、runtime write、secret collection、raw `.git` sync 或 raw conversation sync。 + ## 2026-06-24|Codex Start Here acceptance ledger sync readback **背景**:blocked products owner response acceptance ledger 已推上 Gitea 後,Mac Mini 本機 `~/.codex/CODEX-START-HERE.md` 與 `~/.codex/codex-workstation-sync-dashboard.snapshot.json` 需要再次同步到 MacBook Pro,避免外出開新 Codex 視窗時讀不到 acceptance ledger gate。 diff --git a/docs/operations/CODEX-GITEA-BLOCKED-PRODUCTS-OWNER-RESPONSE-INTAKE-PREFLIGHT-2026-06-24.md b/docs/operations/CODEX-GITEA-BLOCKED-PRODUCTS-OWNER-RESPONSE-INTAKE-PREFLIGHT-2026-06-24.md new file mode 100644 index 00000000..695226ba --- /dev/null +++ b/docs/operations/CODEX-GITEA-BLOCKED-PRODUCTS-OWNER-RESPONSE-INTAKE-PREFLIGHT-2026-06-24.md @@ -0,0 +1,56 @@ +# Codex Gitea Blocked Products Owner Response Intake Preflight + +- generated_at: `2026-06-24T15:05:00+08:00` +- blocked_products: `8` +- response_files: `0` +- owner_response_received: `0 / 8` +- owner_response_accepted: `0 / 8` +- remote_dev_ready: `0 / 8` +- runtime_write_authorized: `0` + +## 目的 + +本階段把 blocked products 的 owner response 從「文件模板」推進成「可執行的 intake preflight」。未來如果收到產品級、已脫敏的 owner response JSON,先由 `scripts/security/blocked-products-owner-response-intake-preflight.py` 掃描、分類、拒收或轉 reviewer checklist。 + +目前尚未收到任何 owner response,所以所有 accepted / remote dev / runtime gate 仍維持 `0`。 + +## 收件位置 + +只允許放置 metadata-only JSON: + +- `docs/operations/blocked-product-owner-responses/` + +目前此目錄只有 README,沒有 response JSON。 + +## Intake Lanes + +| Lane | 用途 | Gate effect | +|------|------|-------------| +| `keep_waiting_owner_response` | 沒有產品級回覆或只有一般批准語句 | received / accepted / remote dev 仍為 0 | +| `quarantine_sensitive_payload` | 疑似 secret / raw payload / `.env` / raw `.git` | 不保存 raw payload,不進 review | +| `reject_execution_request` | 夾帶 repo write / deploy / restart / runtime 操作 | 拒收,不建立 action button | +| `request_supplement` | 欄位不足或 evidence refs 不足 | 補件,不增加 accepted | +| `ready_for_acceptance_review` | 欄位完整且脫敏 | 只進 reviewer checklist | +| `metadata_accepted_waiting_branch_confirmation` | metadata accepted 後仍需分支最終確認 | runtime write 仍 false | + +## 驗證指令 + +```bash +python3 scripts/security/blocked-products-owner-response-intake-preflight.py \ + --root . \ + --generated-at 2026-06-24T15:05:00+08:00 +``` + +預期 readback: + +```text +BLOCKED_PRODUCTS_OWNER_RESPONSE_INTAKE_PREFLIGHT_OK products=8 responses=0 accepted=0 remote_dev_ready=0 runtime_write=0 +``` + +## 邊界 + +- 沒有讀 secret、`.env`、raw conversation、raw `.git` 或 runtime volume。 +- 沒有修改任何產品 repo。 +- 沒有建立 review branch、remote `dev` branch 或 Gitea repo。 +- 沒有部署、restart、reload、DB / K8s / host / firewall / Nginx runtime write。 +- 一般「批准繼續」仍不等於產品級 owner response。 diff --git a/docs/operations/blocked-product-owner-responses/README.md b/docs/operations/blocked-product-owner-responses/README.md new file mode 100644 index 00000000..b98e8868 --- /dev/null +++ b/docs/operations/blocked-product-owner-responses/README.md @@ -0,0 +1,14 @@ +# Blocked Product Owner Responses + +本目錄只允許放「已脫敏、metadata-only」owner response JSON。 + +禁止放入: + +- secret value、token、password、private key、cookie、session、authorization header +- `.env` 內容、hash、partial token、credential derivative +- raw Codex / ChatGPT conversation +- raw `.git` directory 或 git bundle +- runtime volume payload +- 未脫敏 log、backup archive、browser profile、database dump + +目前尚未收到任何可驗收的 owner response,所以此目錄只保留本 README。 diff --git a/docs/operations/codex-gitea-blocked-products-owner-response-intake-preflight.snapshot.json b/docs/operations/codex-gitea-blocked-products-owner-response-intake-preflight.snapshot.json new file mode 100644 index 00000000..a42882fc --- /dev/null +++ b/docs/operations/codex-gitea-blocked-products-owner-response-intake-preflight.snapshot.json @@ -0,0 +1,267 @@ +{ + "schema_version": "codex_gitea_blocked_products_owner_response_intake_preflight_v1", + "generated_at": "2026-06-24T15:05:00+08:00", + "git_commit": "413a0dc8", + "source_ledger_schema_version": "codex_gitea_blocked_products_owner_response_acceptance_v1", + "status": "waiting_owner_response", + "source_ledger": "docs/operations/codex-gitea-blocked-products-owner-response-acceptance.snapshot.json", + "response_directory": "docs/operations/blocked-product-owner-responses", + "summary": { + "blocked_product_count": 8, + "intake_candidate_count": 8, + "response_file_count": 0, + "parsed_response_file_count": 0, + "quarantined_response_file_count": 0, + "valid_redacted_response_file_count": 0, + "required_owner_response_field_count": 14, + "acceptance_check_count": 16, + "rejection_guard_count": 15, + "intake_lane_count": 6, + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "owner_response_rejected_count": 0, + "supplement_requested_count": 0, + "review_branch_ready_count": 0, + "remote_dev_branch_ready_count": 0, + "remote_dev_branch_created_count": 0, + "product_repo_write_authorized_count": 0, + "runtime_write_authorized_count": 0, + "secret_values_collected_count": 0, + "action_button_count": 0 + }, + "intake_lanes": [ + { + "lane_id": "keep_waiting_owner_response", + "instruction": "沒有產品級 redacted owner response,或只有一般批准語句時維持等待。", + "gate_effect": "received / accepted / remote_dev_ready 全部維持 0。" + }, + { + "lane_id": "quarantine_sensitive_payload", + "instruction": "疑似包含 secret、.env、raw conversation、raw .git、runtime volume 或未脫敏 payload 時隔離。", + "gate_effect": "不得保存 raw payload,不得渲染到前端,不得進 acceptance review。" + }, + { + "lane_id": "reject_execution_request", + "instruction": "夾帶 repo write、remote dev branch、deploy、restart、DB、K8s、host 或 runtime 執行要求時拒收。", + "gate_effect": "不得建立 action button,不得轉成執行批准。" + }, + { + "lane_id": "request_supplement", + "instruction": "欄位不足、scope 不清、include / exclude 模糊或 evidence refs 不足時補件。", + "gate_effect": "不得增加 accepted 或 remote_dev_ready。" + }, + { + "lane_id": "ready_for_acceptance_review", + "instruction": "欄位完整、證據脫敏、無敏感 payload 且無執行要求時才可進 reviewer checklist。", + "gate_effect": "仍不是 accepted,也不是 remote dev 授權。" + }, + { + "lane_id": "metadata_accepted_waiting_branch_confirmation", + "instruction": "即使 metadata accepted,也還需要逐產品 branch / baseline final confirmation。", + "gate_effect": "runtime_write 仍必須是 false;remote dev 仍需獨立最終確認。" + } + ], + "required_owner_response_fields": [ + "owner_role_or_team", + "decision", + "decision_reason", + "accepted_baseline_source", + "include_groups", + "exclude_groups", + "quarantined_paths_ack", + "env_secret_policy_ack", + "generated_artifact_policy_ack", + "review_branch_allowed", + "remote_dev_branch_allowed", + "runtime_write_allowed", + "followup_owner", + "evidence_refs" + ], + "acceptance_checks": [ + "owner_role_or_team_is_present", + "decision_is_product_specific", + "decision_reason_is_present", + "baseline_source_is_explicit", + "include_groups_are_specific", + "exclude_groups_are_specific", + "quarantined_paths_ack_is_true", + "env_secret_policy_ack_is_true", + "generated_artifact_policy_ack_is_true", + "review_branch_allowed_is_explicit", + "remote_dev_branch_allowed_is_explicit", + "runtime_write_allowed_is_false", + "evidence_refs_are_redacted", + "followup_owner_is_present", + "no_secret_value_or_partial_secret_present", + "no_raw_conversation_or_raw_git_sync_requested" + ], + "rejection_guards": [ + "generic_approval_phrase_only", + "missing_owner_role_or_team", + "missing_decision_reason", + "missing_baseline_source", + "ambiguous_include_or_exclude_groups", + "requests_secret_value_or_env_content", + "requests_raw_git_directory_sync", + "requests_raw_codex_or_chatgpt_history_sync", + "requests_runtime_volume_sync", + "requests_runtime_write", + "requests_product_repo_write_without_review_branch", + "requests_remote_dev_branch_without_explicit_product_decision", + "includes_generated_outputs_without_sanitized_policy", + "includes_logs_or_backup_archives_without_policy", + "missing_redacted_evidence_refs" + ], + "products": [ + { + "product_id": "clawbot-openclaw", + "status": "waiting_owner_response", + "decision_package": "docs/operations/CLAWBOT-OPENCLAW-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-1 ClawBot / OpenClaw", + "default_blockers": ["owner_response_missing", "two_file_drift_not_accepted"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "tsenyang-website", + "status": "waiting_owner_response", + "decision_package": "docs/operations/TSENYANG-WEBSITE-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-2 Tsenyang Website", + "default_blockers": ["owner_response_missing", "presentation_output_policy_missing"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "agent-bounty-protocol", + "status": "waiting_owner_response", + "decision_package": "docs/operations/AGENT-BOUNTY-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-3 Agent Bounty", + "default_blockers": ["owner_response_missing", "a2a_treasury_scope_not_accepted", "backup_archive_policy_missing"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "2026fifa", + "status": "waiting_owner_response", + "decision_package": "docs/operations/2026FIFA-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-4 2026FIFA", + "default_blockers": ["owner_response_missing", "narrow_scanner_not_completed"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "vibework", + "status": "waiting_owner_response", + "decision_package": "docs/operations/VIBEWORK-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-5 VibeWork", + "default_blockers": ["owner_response_missing", "release_scope_not_split_or_accepted", "diff_check_debt_unresolved"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "stockplatform-v2", + "status": "waiting_owner_response", + "decision_package": "docs/operations/STOCKPLATFORM-V2-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-6 StockPlatform v2", + "default_blockers": ["owner_response_missing", "tmp_generated_outputs_not_excluded", "source_candidate_policy_missing"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "bitan-pharmacy", + "status": "waiting_owner_response", + "decision_package": "docs/operations/BITAN-PHARMACY-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-7 Bitan Pharmacy", + "default_blockers": ["owner_response_missing", "internal_inventory_missing", "public_content_cleanliness_evidence_policy_missing"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + }, + { + "product_id": "vtuber", + "status": "waiting_owner_response", + "decision_package": "docs/operations/VTUBER-DEV-BASELINE-OWNER-DECISION-2026-06-24.md", + "response_template_section": "P1-8 VTuber", + "default_blockers": ["owner_response_missing", "repository_identity_unresolved", "remote_repo_missing"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": false, + "owner_response_accepted": false, + "owner_response_rejected": false, + "supplement_requested": false, + "quarantined": false, + "review_branch_ready": false, + "remote_dev_branch_ready": false, + "runtime_write_authorized": false, + "secret_values_collected": false + } + ], + "inspected_response_files": [], + "hard_gates": [ + "Only redacted owner response metadata JSON files may be inspected.", + "Generic approval text is not a product-specific owner response.", + "Accepted count stays 0 until reviewer acceptance is recorded.", + "Remote dev branch readiness stays 0 until product-specific final confirmation.", + "Runtime write, product repo write, secret collection, raw .git sync, raw conversation sync, .env read, and runtime volume sync remain forbidden." + ], + "secret_values_collected": false, + "env_file_content_read": false, + "raw_git_sync_allowed": false, + "raw_conversation_sync_allowed": false, + "runtime_write_authorized": false, + "product_repo_write_authorized": false +} diff --git a/scripts/security/blocked-products-owner-response-intake-preflight.py b/scripts/security/blocked-products-owner-response-intake-preflight.py new file mode 100644 index 00000000..73cc6240 --- /dev/null +++ b/scripts/security/blocked-products-owner-response-intake-preflight.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +Codex / Gitea blocked products owner response intake preflight. + +This is a repo-only metadata gate. It reads the committed acceptance ledger and +optionally scans redacted owner response metadata files. It must never read +secret values, .env contents, raw Codex conversations, raw .git directories, or +runtime volumes. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +TAIPEI = timezone(timedelta(hours=8)) + +DEFAULT_LEDGER = Path("docs/operations/codex-gitea-blocked-products-owner-response-acceptance.snapshot.json") +DEFAULT_RESPONSE_DIR = Path("docs/operations/blocked-product-owner-responses") + +INTAKE_LANES = [ + { + "lane_id": "keep_waiting_owner_response", + "instruction": "沒有產品級 redacted owner response,或只有一般批准語句時維持等待。", + "gate_effect": "received / accepted / remote_dev_ready 全部維持 0。", + }, + { + "lane_id": "quarantine_sensitive_payload", + "instruction": "疑似包含 secret、.env、raw conversation、raw .git、runtime volume 或未脫敏 payload 時隔離。", + "gate_effect": "不得保存 raw payload,不得渲染到前端,不得進 acceptance review。", + }, + { + "lane_id": "reject_execution_request", + "instruction": "夾帶 repo write、remote dev branch、deploy、restart、DB、K8s、host 或 runtime 執行要求時拒收。", + "gate_effect": "不得建立 action button,不得轉成執行批准。", + }, + { + "lane_id": "request_supplement", + "instruction": "欄位不足、scope 不清、include / exclude 模糊或 evidence refs 不足時補件。", + "gate_effect": "不得增加 accepted 或 remote_dev_ready。", + }, + { + "lane_id": "ready_for_acceptance_review", + "instruction": "欄位完整、證據脫敏、無敏感 payload 且無執行要求時才可進 reviewer checklist。", + "gate_effect": "仍不是 accepted,也不是 remote dev 授權。", + }, + { + "lane_id": "metadata_accepted_waiting_branch_confirmation", + "instruction": "即使 metadata accepted,也還需要逐產品 branch / baseline final confirmation。", + "gate_effect": "runtime_write 仍必須是 false;remote dev 仍需獨立最終確認。", + }, +] + +FORBIDDEN_KEYS = [ + "secret", + "token", + "password", + "private_key", + "cookie", + "session", + "authorization", + "env_content", + "raw_git", + "raw_conversation", + "runtime_volume", +] + + +def git_short_sha(root: Path) -> str: + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + except Exception: + return "unknown" + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def response_files(response_dir: Path) -> list[Path]: + if not response_dir.exists(): + return [] + return sorted(path for path in response_dir.glob("*.json") if path.is_file()) + + +def has_forbidden_key(payload: Any) -> bool: + if isinstance(payload, dict): + for key, value in payload.items(): + normalized = str(key).lower() + if any(forbidden in normalized for forbidden in FORBIDDEN_KEYS): + return True + if has_forbidden_key(value): + return True + elif isinstance(payload, list): + return any(has_forbidden_key(item) for item in payload) + return False + + +def inspect_responses(response_paths: list[Path]) -> list[dict[str, Any]]: + inspected: list[dict[str, Any]] = [] + for path in response_paths: + try: + payload = load_json(path) + parse_ok = True + parse_error = "" + except Exception as exc: + payload = {} + parse_ok = False + parse_error = str(exc) + product_id = payload.get("product_id", path.stem) if isinstance(payload, dict) else path.stem + inspected.append( + { + "path": str(path), + "product_id": product_id, + "parse_ok": parse_ok, + "parse_error": parse_error, + "contains_forbidden_key": has_forbidden_key(payload), + "received": parse_ok, + "accepted": False, + "review_branch_ready": False, + "remote_dev_branch_ready": False, + "runtime_write_authorized": False, + } + ) + return inspected + + +def repo_relative(root: Path, path: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return str(path) + + +def candidate_from_product(product: dict[str, Any]) -> dict[str, Any]: + return { + "product_id": product["product_id"], + "status": "waiting_owner_response", + "decision_package": product["decision_package"], + "response_template_section": product["response_template_section"], + "default_blockers": product["default_blockers"], + "intake_lane": "keep_waiting_owner_response", + "owner_response_received": False, + "owner_response_accepted": False, + "owner_response_rejected": False, + "supplement_requested": False, + "quarantined": False, + "review_branch_ready": False, + "remote_dev_branch_ready": False, + "runtime_write_authorized": False, + "secret_values_collected": False, + } + + +def build_report(root: Path, ledger: dict[str, Any], response_dir: Path, generated_at: str | None) -> dict[str, Any]: + report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") + products = ledger.get("products", []) + response_paths = response_files(response_dir) + inspected = inspect_responses(response_paths) + parsed_received = [item for item in inspected if item["received"] and not item["contains_forbidden_key"]] + quarantined = [item for item in inspected if item["contains_forbidden_key"] or not item["parse_ok"]] + + return { + "schema_version": "codex_gitea_blocked_products_owner_response_intake_preflight_v1", + "generated_at": report_time, + "git_commit": git_short_sha(root), + "source_ledger_schema_version": ledger.get("schema_version"), + "status": "waiting_owner_response", + "source_ledger": str(DEFAULT_LEDGER), + "response_directory": repo_relative(root, response_dir), + "summary": { + "blocked_product_count": len(products), + "intake_candidate_count": len(products), + "response_file_count": len(response_paths), + "parsed_response_file_count": sum(1 for item in inspected if item["parse_ok"]), + "quarantined_response_file_count": len(quarantined), + "valid_redacted_response_file_count": len(parsed_received), + "required_owner_response_field_count": len(ledger.get("required_owner_response_fields", [])), + "acceptance_check_count": len(ledger.get("acceptance_checks", [])), + "rejection_guard_count": len(ledger.get("rejection_guards", [])), + "intake_lane_count": len(INTAKE_LANES), + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "owner_response_rejected_count": 0, + "supplement_requested_count": 0, + "review_branch_ready_count": 0, + "remote_dev_branch_ready_count": 0, + "remote_dev_branch_created_count": 0, + "product_repo_write_authorized_count": 0, + "runtime_write_authorized_count": 0, + "secret_values_collected_count": 0, + "action_button_count": 0, + }, + "intake_lanes": INTAKE_LANES, + "required_owner_response_fields": ledger.get("required_owner_response_fields", []), + "acceptance_checks": ledger.get("acceptance_checks", []), + "rejection_guards": ledger.get("rejection_guards", []), + "products": [candidate_from_product(product) for product in products], + "inspected_response_files": inspected, + "hard_gates": [ + "Only redacted owner response metadata JSON files may be inspected.", + "Generic approval text is not a product-specific owner response.", + "Accepted count stays 0 until reviewer acceptance is recorded.", + "Remote dev branch readiness stays 0 until product-specific final confirmation.", + "Runtime write, product repo write, secret collection, raw .git sync, raw conversation sync, .env read, and runtime volume sync remain forbidden.", + ], + "secret_values_collected": False, + "env_file_content_read": False, + "raw_git_sync_allowed": False, + "raw_conversation_sync_allowed": False, + "runtime_write_authorized": False, + "product_repo_write_authorized": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".", help="Repository root.") + parser.add_argument("--ledger", default=str(DEFAULT_LEDGER), help="Acceptance ledger snapshot path.") + parser.add_argument("--response-dir", default=str(DEFAULT_RESPONSE_DIR), help="Redacted response metadata directory.") + parser.add_argument("--generated-at", default=None, help="Override generated_at timestamp.") + parser.add_argument("--output", default=None, help="Optional output JSON path.") + args = parser.parse_args() + + root = Path(args.root).resolve() + ledger_path = (root / args.ledger).resolve() + response_dir = (root / args.response_dir).resolve() + + ledger = load_json(ledger_path) + report = build_report(root, ledger, response_dir, args.generated_at) + text = json.dumps(report, ensure_ascii=False, indent=2) + "\n" + + if args.output: + Path(args.output).write_text(text, encoding="utf-8") + else: + print(text, end="") + + summary = report["summary"] + print( + "BLOCKED_PRODUCTS_OWNER_RESPONSE_INTAKE_PREFLIGHT_OK " + f"products={summary['blocked_product_count']} " + f"responses={summary['response_file_count']} " + f"accepted={summary['owner_response_accepted_count']} " + f"remote_dev_ready={summary['remote_dev_branch_ready_count']} " + f"runtime_write={summary['runtime_write_authorized_count']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())