{"parentUuid":null,"isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":"I need to understand the StageHand (CreatorFlow) pipeline structure at `/workspace/extra/projects/stagehand/` so I can implement an outer QA→fail→rerender retry loop.\n\nPlease find and read:\n1. The main pipeline runner / CLI entry point (likely in `core/` or `cli/`)\n2. Where the QA review / scoring step runs (look for `hyperframes-video-review`, `qa`, `score`, `review` in file names or content)\n3. Where rendering / reel generation happens (look for `render`, `listing_reel`, `build_reel`)\n4. Any existing retry or loop logic\n5. The workflow config format (if there is one — YAML, JSON, or Python dicts describing steps)\n\nReturn:\n- The key file paths and their roles\n- The current pipeline flow (what calls what, in order)\n- How QA failure is currently communicated (return code? exception? score dict?)\n- How rendering is invoked (CLI command? Python function call?)\n- Any existing retry handling\n\nBe thorough — I need to understand the actual structure before writing code. Search breadth: very thorough."},"uuid":"ea38a0c8-2930-45b7-8536-9688d1b79600","timestamp":"2026-07-21T02:44:54.076Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"ea38a0c8-2930-45b7-8536-9688d1b79600","isSidechain":true,"agentId":"a64b6f12d31791766","attachment":{"type":"deferred_tools_delta","addedNames":["Monitor","PushNotification","RemoteTrigger","SendMessage","TaskStop","TeamCreate","TeamDelete","TodoWrite","WebFetch","WebSearch","mcp__instagram__analyze_post_comments","mcp__instagram__compare_accounts","mcp__instagram__extract_demographics","mcp__instagram__generate_engagement_report","mcp__instagram__identify_leads","mcp__nanoclaw__add_mcp_server","mcp__nanoclaw__add_reaction","mcp__nanoclaw__ask_user_question","mcp__nanoclaw__cancel_task","mcp__nanoclaw__create_agent","mcp__nanoclaw__edit_message","mcp__nanoclaw__install_packages","mcp__nanoclaw__list_tasks","mcp__nanoclaw__pause_task","mcp__nanoclaw__resume_task","mcp__nanoclaw__schedule_task","mcp__nanoclaw__send_card","mcp__nanoclaw__send_file","mcp__nanoclaw__send_message","mcp__nanoclaw__update_task","mcp__twitter-x__advanced_search_tweets","mcp__twitter-x__bookmark_tweet","mcp__twitter-x__check_follow_relationship","mcp__twitter-x__create_article","mcp__twitter-x__create_tweet","mcp__twitter-x__delete_article","mcp__twitter-x__delete_tweet","mcp__twitter-x__download_space","mcp__twitter-x__favorite_tweet","mcp__twitter-x__follow_user","mcp__twitter-x__get_account_info","mcp__twitter-x__get_article","mcp__twitter-x__get_direct_message_conversation","mcp__twitter-x__get_followers_you_know","mcp__twitter-x__get_home_timeline","mcp__twitter-x__get_list_members","mcp__twitter-x__get_payment_history","mcp__twitter-x__get_space_download","mcp__twitter-x__get_space_info","mcp__twitter-x__get_trend_locations","mcp__twitter-x__get_trends","mcp__twitter-x__get_tweet_detail","mcp__twitter-x__get_tweet_replies","mcp__twitter-x__get_tweet_retweeters","mcp__twitter-x__get_tweet_thread","mcp__twitter-x__get_user_about","mcp__twitter-x__get_user_affiliates","mcp__twitter-x__get_user_followers","mcp__twitter-x__get_user_followers_v2","mcp__twitter-x__get_user_following","mcp__twitter-x__get_user_following_v2","mcp__twitter-x__get_user_info","mcp__twitter-x__get_user_info_by_id","mcp__twitter-x__get_user_likes","mcp__twitter-x__get_user_media","mcp__twitter-x__get_user_mentions","mcp__twitter-x__get_user_tweets","mcp__twitter-x__get_user_tweets_and_replies","mcp__twitter-x__get_user_tweets_complete","mcp__twitter-x__get_verified_followers","mcp__twitter-x__join_community","mcp__twitter-x__list_articles","mcp__twitter-x__list_bookmarks","mcp__twitter-x__list_direct_messages","mcp__twitter-x__publish_article","mcp__twitter-x__recover_account_login_v2","mcp__twitter-x__reset_password_confirm","mcp__twitter-x__reset_password_send_code","mcp__twitter-x__retweet_tweet","mcp__twitter-x__search_bookmarks","mcp__twitter-x__search_users","mcp__twitter-x__send_direct_message","mcp__twitter-x__unbookmark_tweet","mcp__twitter-x__unfollow_user","mcp__twitter-x__unpublish_article","mcp__twitter-x__update_article","mcp__twitter-x__update_avatar","mcp__twitter-x__update_banner","mcp__twitter-x__update_profile","mcp__twitter-x__upload_media","mcp__twitter-x__x_login"],"addedLines":["Monitor","PushNotification","RemoteTrigger","SendMessage","TaskStop","TeamCreate","TeamDelete","TodoWrite","WebFetch","WebSearch","mcp__instagram__analyze_post_comments","mcp__instagram__compare_accounts","mcp__instagram__extract_demographics","mcp__instagram__generate_engagement_report","mcp__instagram__identify_leads","mcp__nanoclaw__add_mcp_server","mcp__nanoclaw__add_reaction","mcp__nanoclaw__ask_user_question","mcp__nanoclaw__cancel_task","mcp__nanoclaw__create_agent","mcp__nanoclaw__edit_message","mcp__nanoclaw__install_packages","mcp__nanoclaw__list_tasks","mcp__nanoclaw__pause_task","mcp__nanoclaw__resume_task","mcp__nanoclaw__schedule_task","mcp__nanoclaw__send_card","mcp__nanoclaw__send_file","mcp__nanoclaw__send_message","mcp__nanoclaw__update_task","mcp__twitter-x__advanced_search_tweets","mcp__twitter-x__bookmark_tweet","mcp__twitter-x__check_follow_relationship","mcp__twitter-x__create_article","mcp__twitter-x__create_tweet","mcp__twitter-x__delete_article","mcp__twitter-x__delete_tweet","mcp__twitter-x__download_space","mcp__twitter-x__favorite_tweet","mcp__twitter-x__follow_user","mcp__twitter-x__get_account_info","mcp__twitter-x__get_article","mcp__twitter-x__get_direct_message_conversation","mcp__twitter-x__get_followers_you_know","mcp__twitter-x__get_home_timeline","mcp__twitter-x__get_list_members","mcp__twitter-x__get_payment_history","mcp__twitter-x__get_space_download","mcp__twitter-x__get_space_info","mcp__twitter-x__get_trend_locations","mcp__twitter-x__get_trends","mcp__twitter-x__get_tweet_detail","mcp__twitter-x__get_tweet_replies","mcp__twitter-x__get_tweet_retweeters","mcp__twitter-x__get_tweet_thread","mcp__twitter-x__get_user_about","mcp__twitter-x__get_user_affiliates","mcp__twitter-x__get_user_followers","mcp__twitter-x__get_user_followers_v2","mcp__twitter-x__get_user_following","mcp__twitter-x__get_user_following_v2","mcp__twitter-x__get_user_info","mcp__twitter-x__get_user_info_by_id","mcp__twitter-x__get_user_likes","mcp__twitter-x__get_user_media","mcp__twitter-x__get_user_mentions","mcp__twitter-x__get_user_tweets","mcp__twitter-x__get_user_tweets_and_replies","mcp__twitter-x__get_user_tweets_complete","mcp__twitter-x__get_verified_followers","mcp__twitter-x__join_community","mcp__twitter-x__list_articles","mcp__twitter-x__list_bookmarks","mcp__twitter-x__list_direct_messages","mcp__twitter-x__publish_article","mcp__twitter-x__recover_account_login_v2","mcp__twitter-x__reset_password_confirm","mcp__twitter-x__reset_password_send_code","mcp__twitter-x__retweet_tweet","mcp__twitter-x__search_bookmarks","mcp__twitter-x__search_users","mcp__twitter-x__send_direct_message","mcp__twitter-x__unbookmark_tweet","mcp__twitter-x__unfollow_user","mcp__twitter-x__unpublish_article","mcp__twitter-x__update_article","mcp__twitter-x__update_avatar","mcp__twitter-x__update_banner","mcp__twitter-x__update_profile","mcp__twitter-x__upload_media","mcp__twitter-x__x_login"],"removedNames":[],"readdedNames":[]},"type":"attachment","uuid":"04c8747a-f766-4f08-a7e9-a3f3848fd17f","timestamp":"2026-07-21T02:44:54.080Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"04c8747a-f766-4f08-a7e9-a3f3848fd17f","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESDZDDX7fc6qkqPKimt","type":"message","role":"assistant","content":[{"type":"text","text":"I'll conduct a thorough search of the StageHand pipeline structure to understand the architecture and how QA, rendering, and retry logic work."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":8616,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":8616,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESDYTL9CDSg2bh4HTVG","attributionAgent":"Explore","type":"assistant","uuid":"bc0a878b-60b1-4d51-bf3d-7fd32032a4c1","timestamp":"2026-07-21T02:44:55.299Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"bc0a878b-60b1-4d51-bf3d-7fd32032a4c1","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESDZDDX7fc6qkqPKimt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GMzbgeWudjdVkhTQmu8uwW","name":"Bash","input":{"command":"find /workspace/extra/projects/stagehand -type f -name \"*.py\" | head -20"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":8616,"cache_read_input_tokens":0,"output_tokens":107,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":8616},"inference_geo":"","iterations":[{"input_tokens":3,"output_tokens":107,"cache_read_input_tokens":0,"cache_creation_input_tokens":8616,"cache_creation":{"ephemeral_5m_input_tokens":8616,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESDYTL9CDSg2bh4HTVG","attributionAgent":"Explore","type":"assistant","uuid":"94064425-eb03-446e-9175-86d8268f45e3","timestamp":"2026-07-21T02:44:55.636Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"94064425-eb03-446e-9175-86d8268f45e3","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GMzbgeWudjdVkhTQmu8uwW","type":"tool_result","content":"/workspace/extra/projects/stagehand/core/publishing/__init__.py\n/workspace/extra/projects/stagehand/core/publishing/instagram.py\n/workspace/extra/projects/stagehand/core/hyperframes_review.py\n/workspace/extra/projects/stagehand/core/hyperframes_narration.py\n/workspace/extra/projects/stagehand/core/delivery_naming.py\n/workspace/extra/projects/stagehand/core/config.py\n/workspace/extra/projects/stagehand/core/models.py\n/workspace/extra/projects/stagehand/core/listings/models.py\n/workspace/extra/projects/stagehand/core/listings/manifest.py\n/workspace/extra/projects/stagehand/core/listings/providers/registry.py\n/workspace/extra/projects/stagehand/core/listings/providers/__init__.py\n/workspace/extra/projects/stagehand/core/listings/providers/compass.py\n/workspace/extra/projects/stagehand/core/listings/__init__.py\n/workspace/extra/projects/stagehand/core/listings/review.py\n/workspace/extra/projects/stagehand/core/listings/scorecard.py\n/workspace/extra/projects/stagehand/core/review_server.py\n/workspace/extra/projects/stagehand/core/workflows/store.py\n/workspace/extra/projects/stagehand/core/workflows/runner.py\n/workspace/extra/projects/stagehand/core/workflows/config.py\n/workspace/extra/projects/stagehand/core/workflows/__init__.py","is_error":false}]},"uuid":"a6a46cab-6129-47e1-af03-1f95cbe478de","timestamp":"2026-07-21T02:45:00.618Z","sourceToolAssistantUUID":"94064425-eb03-446e-9175-86d8268f45e3","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"a6a46cab-6129-47e1-af03-1f95cbe478de","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESE3ULMc5csNGVDXkhS","type":"message","role":"assistant","content":[{"type":"text","text":"Let me continue exploring the structure and get more files:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2442,"cache_read_input_tokens":8616,"cache_creation":{"ephemeral_5m_input_tokens":2442,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESE2TZmQgapJ95qxksK","attributionAgent":"Explore","type":"assistant","uuid":"d3b67838-b86a-4f6a-be3a-0444f1ef8fe1","timestamp":"2026-07-21T02:45:01.985Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d3b67838-b86a-4f6a-be3a-0444f1ef8fe1","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESE3ULMc5csNGVDXkhS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G9df8Z7ToU1qES5MmmJxrb","name":"Bash","input":{"command":"find /workspace/extra/projects/stagehand -type f \\( -name \"*.py\" -o -name \"*.yaml\" -o -name \"*.yml\" -o -name \"*.json\" \\) | grep -E \"(cli|main|runner|workflow|render|review|qa)\" | sort"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2442,"cache_read_input_tokens":8616,"output_tokens":132,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2442},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":132,"cache_read_input_tokens":8616,"cache_creation_input_tokens":2442,"cache_creation":{"ephemeral_5m_input_tokens":2442,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESE2TZmQgapJ95qxksK","attributionAgent":"Explore","type":"assistant","uuid":"446a5da2-cb5b-4372-b740-694ecc659f0c","timestamp":"2026-07-21T02:45:02.916Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"446a5da2-cb5b-4372-b740-694ecc659f0c","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G9df8Z7ToU1qES5MmmJxrb","type":"tool_result","content":"<persisted-output>\nOutput too large (68.5KB). Full output saved to: /home/node/.claude/projects/-workspace-agent/033a4a00-9171-4e08-9a79-6b99f177355d/tool-results/b6nbpscmh.txt\n\nPreview (first 2KB):\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/PIL/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/_pytest/main.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/_pytest/runner.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/alembic/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/alembic/autogenerate/render.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/_base_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/_scoped_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/aws/_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/bedrock/_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/tools/_beta_runner.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/tools/_beta_session_runner.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/anthropic/lib/vertex/_client.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/certifi/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/charset_normalizer/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/charset_normalizer/cli/__init__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/charset_normalizer/cli/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/distro/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/dotenv/__main__.py\n/workspace/extra/projects/stagehand/.venv/lib/python3.12/site-packages/dotenv/cli.py\n...\n</persisted-output>","is_error":false}]},"uuid":"7202d215-61a1-4cdf-9fea-4a8eea4a61b9","timestamp":"2026-07-21T02:45:07.130Z","sourceToolAssistantUUID":"446a5da2-cb5b-4372-b740-694ecc659f0c","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"7202d215-61a1-4cdf-9fea-4a8eea4a61b9","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEXES3EcJnCm1q9Efs","type":"message","role":"assistant","content":[{"type":"text","text":"Let me be more specific and look at the core directory structure:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":918,"cache_read_input_tokens":11058,"cache_creation":{"ephemeral_5m_input_tokens":918,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESEWL7ZG1T927YdYd8g","attributionAgent":"Explore","type":"assistant","uuid":"baf847a1-1511-4d85-be91-ca6d73b0cd99","timestamp":"2026-07-21T02:45:08.461Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"baf847a1-1511-4d85-be91-ca6d73b0cd99","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEXES3EcJnCm1q9Efs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012Cz35rYvtEBd3XMGg6usiA","name":"Bash","input":{"command":"ls -la /workspace/extra/projects/stagehand/"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":918,"cache_read_input_tokens":11058,"cache_creation":{"ephemeral_5m_input_tokens":918,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESEWL7ZG1T927YdYd8g","attributionAgent":"Explore","type":"assistant","uuid":"29654dd0-8f9d-4059-a146-b42d168e0ecc","timestamp":"2026-07-21T02:45:08.560Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"29654dd0-8f9d-4059-a146-b42d168e0ecc","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012Cz35rYvtEBd3XMGg6usiA","type":"tool_result","content":"total 60\ndrwxr-xr-x 30 501 dialout   960 Jul 17 16:07 .\ndrwxr-xr-x 75 501 dialout  2400 Jul 20 14:53 ..\n-rw-r--r--  1 501 dialout 14340 Jul 17 16:33 .DS_Store\ndrwxr-xr-x  6 501 dialout   192 Jul 12 18:45 .claude\ndrwxr-xr-x  3 501 dialout    96 Jul 10 18:01 .codex\n-rw-r--r--  1 501 dialout   953 Jul 18 07:29 .env\n-rw-r--r--  1 501 dialout  1191 Jul 11 09:34 .env.example\ndrwxr-xr-x 12 501 dialout   384 Jul 20 21:17 .git\n-rw-r--r--  1 501 dialout   177 Jul 15 20:40 .gitignore\ndrwxr-xr-x  6 501 dialout   192 Jun  2 10:21 .pytest_cache\ndrwxr-xr-x  6 501 dialout   192 Jun 13 15:26 .venv\nlrwxr-xr-x  1 501 dialout     9 Jul 12 13:34 AGENTS.md -> CLAUDE.md\n-rw-r--r--  1 501 dialout  3392 Jul 12 18:45 CLAUDE.md\n-rw-r--r--  1 501 dialout  4280 Jul 10 10:42 README.md\ndrwxr-xr-x  5 501 dialout   160 Jul 11 13:57 cli\ndrwxr-xr-x 16 501 dialout   512 Jul 13 10:31 core\n-rw-r--r--  1 501 dialout   295 Jun  2 10:20 docker-compose.yml\ndrwxr-xr-x 15 501 dialout   480 Jul 20 09:36 docs\ndrwxr-xr-x  7 501 dialout   224 Jul 11 14:59 input\ndrwxr-xr-x  4 501 dialout   128 Jun  2 13:03 memory\ndrwxr-xr-x 40 501 dialout  1280 Jul 15 09:22 output\n-rw-r--r--  1 501 dialout   361 Jun  2 10:20 pyproject.toml\n-rw-r--r--  1 501 dialout    29 Jul 14 19:25 redirectTo.md\n-rw-r--r--  1 501 dialout    27 Jun  2 10:20 requirements-dev.txt\n-rw-r--r--  1 501 dialout   117 Jun  2 12:02 requirements.txt\ndrwxr-xr-x  6 501 dialout   192 Jul 20 09:53 scripts\ndrwxr-xr-x  3 501 dialout    96 Jul 11 10:53 skills\ndrwxr-xr-x 29 501 dialout   928 Jul 20 09:35 tests\ndrwxr-xr-x  2 501 dialout    64 Jun  2 10:06 tmp\ndrwxr-xr-x  5 501 dialout   160 Jul  7 14:13 workflows","is_error":false}]},"uuid":"cef6d303-18f1-49cf-b2cd-ad7551ce8575","timestamp":"2026-07-21T02:45:08.583Z","sourceToolAssistantUUID":"29654dd0-8f9d-4059-a146-b42d168e0ecc","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"cef6d303-18f1-49cf-b2cd-ad7551ce8575","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEdF5o8gbURjfkV2YM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013YxTFFfogaxVzCJ9ZBAgqS","name":"Bash","input":{"command":"ls -la /workspace/extra/projects/stagehand/cli/"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1008,"cache_read_input_tokens":11976,"output_tokens":67,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1008},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":67,"cache_read_input_tokens":11976,"cache_creation_input_tokens":1008,"cache_creation":{"ephemeral_5m_input_tokens":1008,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESEcQUsC5idFN3hfuFT","attributionAgent":"Explore","type":"assistant","uuid":"74013bf5-a2c0-4f87-b402-078e0e657704","timestamp":"2026-07-21T02:45:09.940Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"74013bf5-a2c0-4f87-b402-078e0e657704","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013YxTFFfogaxVzCJ9ZBAgqS","type":"tool_result","content":"total 28\ndrwxr-xr-x  5 501 dialout   160 Jul 11 13:57 .\ndrwxr-xr-x 30 501 dialout   960 Jul 17 16:07 ..\n-rw-r--r--  1 501 dialout    31 Jun  2 10:20 __init__.py\ndrwxr-xr-x  6 501 dialout   192 Jul 18 07:51 __pycache__\n-rw-r--r--  1 501 dialout 23638 Jul 18 07:51 main.py","is_error":false}]},"uuid":"16e30e41-7c00-401a-9c9f-589f8410236a","timestamp":"2026-07-21T02:45:09.956Z","sourceToolAssistantUUID":"74013bf5-a2c0-4f87-b402-078e0e657704","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"16e30e41-7c00-401a-9c9f-589f8410236a","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEj4LCEBjb1HQXvmYf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EVCsecxoMyMqqFpUqwarNC","name":"Bash","input":{"command":"ls -la /workspace/extra/projects/stagehand/core/"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":235,"cache_read_input_tokens":12984,"output_tokens":67,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":235},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":67,"cache_read_input_tokens":12984,"cache_creation_input_tokens":235,"cache_creation":{"ephemeral_5m_input_tokens":235,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESEiLBLfpTvgUTWHFDP","attributionAgent":"Explore","type":"assistant","uuid":"de49e912-5482-4cab-9783-bc46be90dd67","timestamp":"2026-07-21T02:45:11.257Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"de49e912-5482-4cab-9783-bc46be90dd67","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EVCsecxoMyMqqFpUqwarNC","type":"tool_result","content":"total 60\ndrwxr-xr-x 16 501 dialout   512 Jul 13 10:31 .\ndrwxr-xr-x 30 501 dialout   960 Jul 17 16:07 ..\n-rw-r--r--  1 501 dialout    32 Jun  2 10:20 __init__.py\ndrwxr-xr-x 13 501 dialout   416 Jul 20 09:03 __pycache__\n-rw-r--r--  1 501 dialout  3333 Jul 11 09:31 config.py\ndrwxr-xr-x  7 501 dialout   224 Jun  2 10:25 db\n-rw-r--r--  1 501 dialout  4121 Jul 13 11:00 delivery_naming.py\n-rw-r--r--  1 501 dialout  3219 Jul 18 07:50 hyperframes_narration.py\n-rw-r--r--  1 501 dialout 28051 Jul 11 13:20 hyperframes_review.py\ndrwxr-xr-x  8 501 dialout   256 Jun  2 16:54 integrations\ndrwxr-xr-x  9 501 dialout   288 Jul 11 09:31 listings\ndrwxr-xr-x 10 501 dialout   320 Jul 11 15:02 media\n-rw-r--r--  1 501 dialout  3134 Jul  7 14:28 models.py\ndrwxr-xr-x  5 501 dialout   160 Jun 13 15:25 publishing\n-rw-r--r--  1 501 dialout  7378 Jul 14 19:42 review_server.py\ndrwxr-xr-x  9 501 dialout   288 Jul 10 11:36 workflows","is_error":false}]},"uuid":"d134a297-cbb8-478b-85fe-3fb127a94494","timestamp":"2026-07-21T02:45:11.274Z","sourceToolAssistantUUID":"de49e912-5482-4cab-9783-bc46be90dd67","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d134a297-cbb8-478b-85fe-3fb127a94494","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEpwo2ERXABvTqk8YE","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011adb4iGc28UF5GYFvfMJdR","name":"Bash","input":{"command":"ls -la /workspace/extra/projects/stagehand/core/workflows/"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":576,"cache_read_input_tokens":13219,"output_tokens":69,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":576},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":69,"cache_read_input_tokens":13219,"cache_creation_input_tokens":576,"cache_creation":{"ephemeral_5m_input_tokens":576,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESEp7gwUbe3Gnj7Sr5G","attributionAgent":"Explore","type":"assistant","uuid":"bd3353c2-62a5-4458-92d4-52a86c16d0ad","timestamp":"2026-07-21T02:45:12.683Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"bd3353c2-62a5-4458-92d4-52a86c16d0ad","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011adb4iGc28UF5GYFvfMJdR","type":"tool_result","content":"total 72\ndrwxr-xr-x  9 501 dialout   288 Jul 10 11:36 .\ndrwxr-xr-x 16 501 dialout   512 Jul 13 10:31 ..\n-rw-r--r--  1 501 dialout    38 Jun  2 10:20 __init__.py\ndrwxr-xr-x 14 501 dialout   448 Jul 13 10:37 __pycache__\n-rw-r--r--  1 501 dialout  1122 Jun  2 10:20 config.py\n-rw-r--r--  1 501 dialout 15867 Jul 11 11:19 listing_reel_modules.py\n-rw-r--r--  1 501 dialout 33878 Jul 13 10:37 modules.py\n-rw-r--r--  1 501 dialout  5264 Jul  7 14:29 runner.py\n-rw-r--r--  1 501 dialout  2111 Jun  2 10:20 store.py","is_error":false}]},"uuid":"0796ee6a-5916-4701-8ab8-87b73dd74d98","timestamp":"2026-07-21T02:45:12.692Z","sourceToolAssistantUUID":"bd3353c2-62a5-4458-92d4-52a86c16d0ad","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"0796ee6a-5916-4701-8ab8-87b73dd74d98","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESEw1B5qQhEgBqeZhwZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Aa8GMXE7the1Cxy9zJ9E7o","name":"Bash","input":{"command":"ls -la /workspace/extra/projects/stagehand/core/listings/"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":366,"cache_read_input_tokens":13795,"output_tokens":69,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":366},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":69,"cache_read_input_tokens":13795,"cache_creation_input_tokens":366,"cache_creation":{"ephemeral_5m_input_tokens":366,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESEvBZpGKre7jPjh7EX","attributionAgent":"Explore","type":"assistant","uuid":"99720e5c-9335-43d9-bbaa-659210fcdcad","timestamp":"2026-07-21T02:45:14.092Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"99720e5c-9335-43d9-bbaa-659210fcdcad","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Aa8GMXE7the1Cxy9zJ9E7o","type":"tool_result","content":"total 48\ndrwxr-xr-x  9 501 dialout   288 Jul 11 09:31 .\ndrwxr-xr-x 16 501 dialout   512 Jul 13 10:31 ..\n-rw-r--r--  1 501 dialout   193 Jul  7 14:02 __init__.py\ndrwxr-xr-x 15 501 dialout   480 Jul 20 09:03 __pycache__\n-rw-r--r--  1 501 dialout 12247 Jul  7 14:26 manifest.py\n-rw-r--r--  1 501 dialout  3887 Jul 11 09:31 models.py\ndrwxr-xr-x  6 501 dialout   192 Jul 20 09:35 providers\n-rw-r--r--  1 501 dialout  8543 Jul 11 09:31 review.py\n-rw-r--r--  1 501 dialout 14319 Jul 11 11:19 scorecard.py","is_error":false}]},"uuid":"3b9505ef-7406-4287-a610-d56ed7c2a422","timestamp":"2026-07-21T02:45:14.115Z","sourceToolAssistantUUID":"99720e5c-9335-43d9-bbaa-659210fcdcad","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"3b9505ef-7406-4287-a610-d56ed7c2a422","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESF3CzAJ9T8YZ5yhhRx","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read the key files to understand the pipeline structure:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":464,"cache_read_input_tokens":14161,"cache_creation":{"ephemeral_5m_input_tokens":464,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESF2Pd2R9mmoYePnZd4","attributionAgent":"Explore","type":"assistant","uuid":"c2e7215e-34ce-49cf-8ddf-396f8205efb5","timestamp":"2026-07-21T02:45:15.423Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"c2e7215e-34ce-49cf-8ddf-396f8205efb5","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESF3CzAJ9T8YZ5yhhRx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WkV4KR2G24NAhc2vNjXyLM","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/cli/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":464,"cache_read_input_tokens":14161,"cache_creation":{"ephemeral_5m_input_tokens":464,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESF2Pd2R9mmoYePnZd4","attributionAgent":"Explore","type":"assistant","uuid":"f07cbce7-ec5a-48bc-8e56-462dcd34aa19","timestamp":"2026-07-21T02:45:15.876Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"f07cbce7-ec5a-48bc-8e56-462dcd34aa19","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WkV4KR2G24NAhc2vNjXyLM","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport argparse\n4\timport json\n5\timport os\n6\timport subprocess\n7\timport sys\n8\tfrom pathlib import Path\n9\tfrom http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler\n10\tfrom typing import Any\n11\t\n12\tfrom core.config import load_settings\n13\tfrom core.hyperframes_narration import add_narration_to_project, delivery_video_path_for_project\n14\tfrom core.hyperframes_review import (\n15\t    review_hyperframes_project,\n16\t    review_hyperframes_script,\n17\t    write_hyperframes_review,\n18\t)\n19\tfrom core.media.ffmpeg_utils import assert_media_has_usable_audio\n20\tfrom core.review_server import RangeRequestHandler, ReviewLaunchAgentSpec\n21\tfrom core.workflows.config import load_workflow_config\n22\tfrom core.workflows.modules import LocalTranscriptionModule, default_modules\n23\tfrom core.workflows.runner import WorkflowRunner\n24\tfrom core.workflows.store import JsonJobStore\n25\t\n26\t\n27\tdef main(argv: list[str] | None = None) -> int:\n28\t    parser = argparse.ArgumentParser(prog=\"creatorflow\")\n29\t    parser.add_argument(\"--env-file\", default=\".env\")\n30\t    subparsers = parser.add_subparsers(dest=\"command\", required=True)\n31\t\n32\t    run_parser = subparsers.add_parser(\"run\")\n33\t    run_parser.add_argument(\"workflow\")\n34\t    run_parser.add_argument(\"--input\", required=True)\n35\t    run_parser.add_argument(\"--tenant\", default=\"demo\")\n36\t    run_parser.add_argument(\"--listing-description\", default=\"\")\n37\t    run_parser.add_argument(\"--use-demo-env\", action=\"store_true\", help=\"Use local-only demo context from .env\")\n38\t    run_parser.add_argument(\"--include-demo-address\", action=\"store_true\", help=\"Include DEMO_PROPERTY_ADDRESS in local demo context\")\n39\t    run_parser.add_argument(\"--keep-original-audio\", action=\"store_true\")\n40\t    run_parser.add_argument(\"--render\", action=\"store_true\", help=\"Execute local ffmpeg rendering after approval\")\n41\t    run_parser.add_argument(\"--tts-provider\", choices=[\"say\", \"coqui\", \"elevenlabs\", \"silent\"], default=\"say\")\n42\t    run_parser.add_argument(\"--voice-id\", default=None, help=\"ElevenLabs voice ID (overrides DEFAULT_VOICE_ID in .env)\")\n43\t    run_parser.add_argument(\"--say-voice\", default=None)\n44\t    run_parser.add_argument(\"--say-rate\", type=int, default=None)\n45\t    run_parser.add_argument(\n46\t        \"--transcription-provider\",\n47\t        choices=[\"whisper\", \"local-whisper\", \"none\"],\n48\t        default=\"none\",\n49\t        help=(\n50\t            \"Transcription provider. \"\n51\t            \"'whisper' uses OpenAI Whisper API (requires OPENAI_API_KEY). \"\n52\t            \"'local-whisper' runs mlx-whisper on-device (Apple Silicon, no API key needed).\"\n53\t        ),\n54\t    )\n55\t\n56\t    status_parser = subparsers.add_parser(\"status\")\n57\t    status_parser.add_argument(\"job_id\")\n58\t\n59\t    approve_parser = subparsers.add_parser(\"approve\")\n60\t    approve_parser.add_argument(\"job_id\")\n61\t    approve_parser.add_argument(\"--script\", required=True)\n62\t    approve_parser.add_argument(\"--approved-by\", default=\"operator\")\n63\t\n64\t    reject_parser = subparsers.add_parser(\"reject\")\n65\t    reject_parser.add_argument(\"job_id\")\n66\t    reject_parser.add_argument(\"--reason\", required=True)\n67\t\n68\t    resume_parser = subparsers.add_parser(\"resume\", help=\"Retry an approved job from its failed step\")\n69\t    resume_parser.add_argument(\"job_id\")\n70\t\n71\t    touch_parser = subparsers.add_parser(\"log-touch\")\n72\t    touch_parser.add_argument(\"job_id\")\n73\t    touch_parser.add_argument(\"--minutes\", required=True, type=float)\n74\t    touch_parser.add_argument(\"--note\", default=\"\")\n75\t\n76\t    publish_parser = subparsers.add_parser(\n77\t        \"publish\",\n78\t        help=\"Package a rendered video for manual Instagram posting (pauses for caption approval)\",\n79\t    )\n80\t    publish_parser.add_argument(\"--input\", required=True, help=\"Path to the rendered video to publish\")\n81\t    publish_parser.add_argument(\"--tenant\", default=\"demo\")\n82\t    publish_parser.add_argument(\"--handle\", default=\"\", help=\"Instagram @handle for this tenant\")\n83\t    publish_parser.add_argument(\"--hashtags\", default=\"\", help='Space/comma separated hashtags, e.g. \"#realestate #cedarpark\"')\n84\t    publish_parser.add_argument(\"--cta\", default=\"\", help=\"Call-to-action line, e.g. 'DM for a tour.'\")\n85\t    publish_parser.add_argument(\"--listing-description\", default=\"\", help=\"Selling points to fold into the caption\")\n86\t\n87\t    review_server_parser = subparsers.add_parser(\n88\t        \"review-server\",\n89\t        help=\"Serve output artifacts for private review over a local/Tailscale-reachable HTTP endpoint\",\n90\t    )\n91\t    review_server_parser.add_argument(\"--host\", default=\"0.0.0.0\")\n92\t    review_server_parser.add_argument(\"--port\", type=int, default=8421)\n93\t    review_server_parser.add_argument(\"--directory\", default=None, help=\"Artifact root to serve (defaults to OUTPUT_DIR)\")\n94\t\n95\t    review_server_install_parser = subparsers.add_parser(\n96\t        \"review-server-install\",\n97\t        help=\"Install a macOS LaunchAgent so the review server survives reboots\",\n98\t    )\n99\t    review_server_install_parser.add_argument(\"--host\", default=\"127.0.0.1\")\n100\t    review_server_install_parser.add_argument(\"--port\", type=int, default=8421)\n101\t    review_server_install_parser.add_argument(\"--directory\", default=None, help=\"Artifact root to serve (defaults to OUTPUT_DIR)\")\n102\t    review_server_install_parser.add_argument(\"--label\", default=\"com.james.stagehand-review-server\")\n103\t\n104\t    for command_name, help_text in (\n105\t        (\"hyperframes-script-review\", \"Run pre-render script review for a HyperFrames reel project\"),\n106\t        (\"hyperframes-video-review\", \"Run post-render video review for a HyperFrames reel project\"),\n107\t        (\"hyperframes-review\", \"Compatibility alias for HyperFrames video review\"),\n108\t    ):\n109\t        parser_ref = subparsers.add_parser(command_name, help=help_text)\n110\t        parser_ref.add_argument(\"--project\", required=True, help=\"Path to the HyperFrames project directory\")\n111\t        parser_ref.add_argument(\"--reference\", default=None, help=\"Optional reference HyperFrames project for comparison\")\n112\t        parser_ref.add_argument(\"--skip-cli-checks\", action=\"store_true\")\n113\t        parser_ref.add_argument(\"--skip-frame-extraction\", action=\"store_true\")\n114\t\n115\t    narration_parser = subparsers.add_parser(\n116\t        \"hyperframes-add-narration\",\n117\t        help=\"Synthesize narration and mux it into a HyperFrames reel project's final video \"\n118\t        \"(standard fix for a hyperframes-video-review missing-narration hard failure)\",\n119\t    )\n120\t    narration_parser.add_argument(\"--project\", required=True, help=\"Path to the HyperFrames project directory\")\n121\t    narration_text_group = narration_parser.add_mutually_exclusive_group(required=True)\n122\t    narration_text_group.add_argument(\"--text\", default=None, help=\"Narration text to speak\")\n123\t    narration_text_group.add_argument(\"--text-file\", default=None, help=\"Path to a text file with the narration\")\n124\t    narration_parser.add_argument(\"--voice\", default=None, help=\"say voice (defaults to SAY_VOICE in .env)\")\n125\t    narration_parser.add_argument(\"--rate\", type=int, default=None, help=\"say rate (defaults to SAY_RATE in .env)\")\n126\t\n127\t    audio_check_parser = subparsers.add_parser(\n128\t        \"hyperframes-assert-audio\",\n129\t        help=\"Fail unless a HyperFrames reel MP4 has a usable non-silent audio stream; does not launch Chrome\",\n130\t    )\n131\t    audio_check_target = audio_check_parser.add_mutually_exclusive_group(required=True)\n132\t    audio_check_target.add_argument(\"--project\", default=None, help=\"Path to the HyperFrames project directory\")\n133\t    audio_check_target.add_argument(\"--video\", default=None, help=\"Path to a rendered MP4\")\n134\t    audio_check_parser.add_argument(\n135\t        \"--final-video-name\",\n136\t        default=\"final_listing_reel.mp4\",\n137\t        help=\"Final video filename used with --project\",\n138\t    )\n139\t\n140\t    listing_parser = subparsers.add_parser(\n141\t        \"listing-reel\",\n142\t        help=\"Create an approved Instagram reel from an authorized listing source or local manifest\",\n143\t    )\n144\t    listing_parser.add_argument(\n145\t        \"--url\",\n146\t        required=True,\n147\t        help=\"Canonical public listing URL (identity; may open an allowlisted demo adapter with --demo)\",\n148\t    )\n149\t    listing_parser.add_argument(\n150\t        \"--manifest\",\n151\t        default=None,\n152\t        help=\"Authorized local listing manifest JSON (optional when using --demo / --rights-confirmed adapter intake)\",\n153\t    )\n154\t    listing_parser.add_argument(\n155\t        \"--media-root\",\n156\t        default=None,\n157\t        help=\"Root for local photos / downloaded demo media (defaults next to the manifest or under the job workspace)\",\n158\t    )\n159\t    listing_parser.add_argument(\n160\t        \"--demo\",\n161\t        action=\"store_true\",\n162\t        help=\"Enable demo-only link intake for allowlisted sources (Compass). Not production rights clearance.\",\n163\t    )\n164\t    listing_parser.add_argument(\n165\t        \"--rights-confirmed\",\n166\t        action=\"store_true\",\n167\t        help=\"Operator asserts broker/photographer/MLS rights for this listing media (required for non-demo adapter production path).\",\n168\t    )\n169\t    listing_parser.add_argument(\"--tenant\", default=\"demo\")\n170\t    listing_parser.add_argument(\"--agent-name\", default=None)\n171\t    listing_parser.add_argument(\"--handle\", default=None, help=\"Public Instagram handle\")\n172\t    listing_parser.add_argument(\"--phone\", default=None, help=\"Public contact phone\")\n173\t    listing_parser.add_argument(\"--email\", default=None, help=\"Public contact email\")\n174\t    listing_parser.add_argument(\"--website\", default=None, help=\"Public contact website\")\n175\t    listing_parser.add_argument(\"--profile-photo\", default=None, help=\"Public profile photo path for the contact outro\")\n176\t    listing_parser.add_argument(\"--hashtags\", default=\"#realestate #austinrealestate\")\n177\t    listing_parser.add_argument(\"--target-duration\", type=float, default=30.0)\n178\t    listing_parser.add_argument(\n179\t        \"--tts-provider\",\n180\t        choices=[\"elevenlabs\", \"say\", \"coqui\", \"silent\"],\n181\t        default=\"elevenlabs\",\n182\t    )\n183\t    listing_parser.add_argument(\"--voice-id\", default=None, help=\"ElevenLabs voice ID (overrides DEFAULT_VOICE_ID)\")\n184\t    listing_parser.add_argument(\"--voice-owner\", default=None, help=\"Verified owner of the selected cloned voice\")\n185\t    listing_parser.add_argument(\"--say-voice\", default=None)\n186\t    listing_parser.add_argument(\"--say-rate\", type=int, default=None)\n187\t\n188\t    args = parser.parse_args(argv)\n189\t    settings = load_settings(args.env_file)\n190\t    store = JsonJobStore(Path(settings.output_dir) / \"jobs\")\n191\t\n192\t    if args.command == \"status\":\n193\t        job = store.get(args.job_id)\n194\t        print(json.dumps({\"id\": job.id, \"status\": job.status, \"approval_status\": job.approval_status}, indent=2))\n195\t        return 0\n196\t    if args.command == \"review-server\":\n197\t        directory = Path(args.directory or settings.output_dir).resolve()\n198\t        directory.mkdir(parents=True, exist_ok=True)\n199\t        handler = lambda *a, **kw: RangeRequestHandler(*a, directory=str(directory), **kw)\n200\t        server = ThreadingHTTPServer((args.host, args.port), handler)\n201\t        print(json.dumps({\"status\": \"serving\", \"directory\": str(directory), \"host\": args.host, \"port\": args.port}, indent=2))\n202\t        try:\n203\t            server.serve_forever()\n204\t        except KeyboardInterrupt:\n205\t            server.server_close()\n206\t        return 0\n207\t    if args.command == \"review-server-install\":\n208\t        if sys.platform != \"darwin\":\n209\t            raise SystemExit(\"review-server-install currently supports macOS LaunchAgents only\")\n210\t        directory = Path(args.directory or settings.output_dir).resolve()\n211\t        directory.mkdir(parents=True, exist_ok=True)\n212\t        repo_root = Path.cwd().resolve()\n213\t        venv_python = repo_root / \".venv\" / \"bin\" / \"python\"\n214\t        launch_agents_dir = Path.home() / \"Library\" / \"LaunchAgents\"\n215\t        launch_agents_dir.mkdir(parents=True, exist_ok=True)\n216\t        plist_path = launch_agents_dir / f\"{args.label}.plist\"\n217\t        spec = ReviewLaunchAgentSpec(\n218\t            label=args.label,\n219\t            python_executable=venv_python if venv_python.exists() else Path(sys.executable).resolve(),\n220\t            repo_root=repo_root,\n221\t            env_file=Path(args.env_file).resolve(),\n222\t            host=args.host,\n223\t            port=args.port,\n224\t            directory=directory,\n225\t            stdout_path=Path(\"/tmp\") / f\"{args.label}.out.log\",\n226\t            stderr_path=Path(\"/tmp\") / f\"{args.label}.err.log\",\n227\t        )\n228\t        plist_path.write_bytes(spec.plist_bytes())\n229\t        uid = str(os.getuid())\n230\t        subprocess.run([\"launchctl\", \"bootout\", f\"gui/{uid}\", str(plist_path)], check=False, capture_output=True, text=True)\n231\t        subprocess.run([\"launchctl\", \"bootstrap\", f\"gui/{uid}\", str(plist_path)], check=True, capture_output=True, text=True)\n232\t        subprocess.run([\"launchctl\", \"kickstart\", \"-k\", f\"gui/{uid}/{args.label}\"], check=True, capture_output=True, text=True)\n233\t        print(json.dumps({\"status\": \"installed\", \"label\": args.label, \"plist_path\": str(plist_path), \"directory\": str(directory), \"host\": args.host, \"port\": args.port}, indent=2))\n234\t        return 0\n235\t    if args.command in {\"hyperframes-review\", \"hyperframes-video-review\", \"hyperframes-script-review\"}:\n236\t        project_dir = Path(args.project).resolve()\n237\t        reference_dir = Path(args.reference).resolve() if args.reference else None\n238\t        if args.command == \"hyperframes-script-review\":\n239\t            report = review_hyperframes_script(\n240\t                project_dir,\n241\t                reference_dir=reference_dir,\n242\t                run_cli_checks=not args.skip_cli_checks,\n243\t            )\n244\t            output_dir = project_dir / \"qa\" / \"script_review\"\n245\t        else:\n246\t            report = review_hyperframes_project(\n247\t                project_dir,\n248\t                reference_dir=reference_dir,\n249\t                run_cli_checks=not args.skip_cli_checks,\n250\t                extract_frames=not args.skip_frame_extraction,\n251\t            )\n252\t            output_dir = project_dir / \"qa\" / \"video_review\"\n253\t        json_path, md_path = write_hyperframes_review(report, output_dir)\n254\t        print(json.dumps({\"report_path\": str(json_path), \"summary_path\": str(md_path), \"overall_score\": report.overall_score, \"recommendation\": report.recommendation}, indent=2))\n255\t        return 0\n256\t    if args.command == \"hyperframes-add-narration\":\n257\t        project_dir = Path(args.project).resolve()\n258\t        narration_text = args.text if args.text is not None else Path(args.text_file).read_text(encoding=\"utf-8\")\n259\t        final_video = add_narration_to_project(\n260\t            project_dir,\n261\t            narration_text,\n262\t            voice=args.voice or settings.say_voice,\n263\t            rate=args.rate or settings.say_rate,\n264\t        )\n265\t        print(\n266\t            json.dumps(\n267\t                {\n268\t                    \"final_video_path\": str(final_video),\n269\t                    \"delivery_video_path\": str(delivery_video_path_for_project(project_dir, final_video)),\n270\t                },\n271\t                indent=2,\n272\t            )\n273\t        )\n274\t        return 0\n275\t    if args.command == \"hyperframes-assert-audio\":\n276\t        video_path = (\n277\t            Path(args.video).resolve()\n278\t            if args.video\n279\t            else (Path(args.project).resolve() / args.final_video_name)\n280\t        )\n281\t        try:\n282\t            probe, peak_volume_db = assert_media_has_usable_audio(video_path)\n283\t        except ValueError as exc:\n284\t            raise SystemExit(str(exc)) from exc\n285\t        print(\n286\t            json.dumps(\n287\t                {\n288\t                    \"video_path\": str(video_path),\n289\t                    \"duration_seconds\": probe.duration_seconds,\n290\t                    \"has_audio\": probe.has_audio,\n291\t                    \"audio_codec\": probe.audio_codec,\n292\t                    \"audio_peak_db\": peak_volume_db,\n293\t                },\n294\t                indent=2,\n295\t            )\n296\t        )\n297\t        return 0\n298\t\n299\t    existing_job = None\n300\t    if args.command == \"publish\":\n301\t        workflow_name = \"publish_instagram\"\n302\t    elif args.command in (\"approve\", \"resume\", \"reject\", \"log-touch\"):\n303\t        # Resume/operate on an existing job using the workflow it was started with,\n304\t        # not whatever the subcommand would default to.\n305\t        existing_job = store.get(args.job_id)\n306\t        workflow_name = existing_job.workflow_name\n307\t    elif args.command == \"listing-reel\":\n308\t        workflow_name = \"listing_reel\"\n309\t    else:\n310\t        workflow_name = getattr(args, \"workflow\", \"video_narration\")\n311\t    transcription_client = _build_transcription_client(args, settings)\n312\t    elevenlabs_client = _build_elevenlabs_client(\n313\t        args,\n314\t        settings,\n315\t        job_input=(\n316\t            existing_job.input_params\n317\t            if existing_job is not None and args.command in (\"approve\", \"resume\")\n318\t            else None\n319\t        ),\n320\t    )\n321\t    runner = _runner(workflow_name, store, transcription_client=transcription_client, elevenlabs_client=elevenlabs_client)\n322\t\n323\t    if args.command == \"run\":\n324\t        listing_description = args.listing_description\n325\t        if args.use_demo_env and not listing_description:\n326\t            listing_description = settings.demo_listing_description(include_address=args.include_demo_address)\n327\t        job = runner.start(\n328\t            tenant_id=args.tenant,\n329\t            input_params={\n330\t                \"input_path\": args.input,\n331\t                \"listing_description\": listing_description,\n332\t                \"keep_original_audio\": args.keep_original_audio,\n333\t                \"execute_render\": args.render,\n334\t                \"local_tts_provider\": args.tts_provider,\n335\t                \"say_voice\": args.say_voice or settings.say_voice,\n336\t                \"say_rate\": args.say_rate or settings.say_rate,\n337\t                \"coqui_tts_platform\": settings.coqui_tts_platform,\n338\t                \"coqui_tts_image\": settings.coqui_tts_image,\n339\t                \"coqui_tts_model\": settings.coqui_tts_model,\n340\t            },\n341\t        )\n342\t    elif args.command == \"publish\":\n343\t        job = runner.start(\n344\t            tenant_id=args.tenant,\n345\t            input_params={\n346\t                \"input_path\": args.input,\n347\t                \"listing_description\": args.listing_description,\n348\t                \"instagram_handle\": args.handle,\n349\t                \"instagram_hashtags\": args.hashtags,\n350\t                \"instagram_cta\": args.cta,\n351\t            },\n352\t        )\n353\t    elif args.command == \"listing-reel\":\n354\t        provider = args.tts_provider\n355\t        agent_name = args.agent_name or settings.agent_name or settings.demo_agent_name\n356\t        agent_handle = args.handle if args.handle is not None else settings.agent_instagram_handle\n357\t        agent_phone = args.phone if args.phone is not None else settings.agent_phone\n358\t        agent_email = args.email if args.email is not None else settings.agent_email\n359\t        agent_website = args.website if args.website is not None else settings.agent_website\n360\t        agent_profile_photo = args.profile_photo if args.profile_photo is not None else settings.agent_profile_photo\n361\t        voice_owner = args.voice_owner or settings.voice_clone_owner\n362\t        if not agent_name:\n363\t            raise SystemExit(\"listing-reel requires --agent-name or AGENT_NAME in .env\")\n364\t        if not any((agent_handle, agent_phone, agent_email, agent_website)):\n365\t            raise SystemExit(\"listing-reel requires at least one public contact field\")\n366\t        if not args.manifest and not (args.demo or args.rights_confirmed):\n367\t            raise SystemExit(\n368\t                \"listing-reel without --manifest requires --demo (pilot link intake) \"\n369\t                \"or --rights-confirmed once brokerage authorization is in hand\"\n370\t            )\n371\t        media_root = args.media_root\n372\t        if media_root is None and args.manifest:\n373\t            media_root = str(Path(args.manifest).parent)\n374\t        job = runner.start(\n375\t            tenant_id=args.tenant,\n376\t            input_params={\n377\t                \"listing_url\": args.url,\n378\t                \"listing_manifest_path\": args.manifest,\n379\t                \"listing_media_root\": media_root,\n380\t                \"listing_demo_intake\": bool(args.demo),\n381\t                \"listing_rights_confirmed\": bool(args.rights_confirmed),\n382\t                \"agent_name\": agent_name,\n383\t                \"agent_instagram_handle\": agent_handle,\n384\t                \"agent_phone\": agent_phone,\n385\t                \"agent_email\": agent_email,\n386\t                \"agent_website\": agent_website,\n387\t                \"agent_profile_photo\": agent_profile_photo,\n388\t                \"instagram_handle\": agent_handle,\n389\t                \"instagram_hashtags\": args.hashtags,\n390\t                \"voice_owner\": voice_owner,\n391\t                \"voice_id\": args.voice_id or settings.default_voice_id,\n392\t                \"local_tts_provider\": provider,\n393\t                \"require_audible_narration\": provider != \"silent\",\n394\t                \"execute_render\": True,\n395\t                \"target_duration_seconds\": args.target_duration,\n396\t                \"say_voice\": args.say_voice or settings.say_voice,\n397\t                \"say_rate\": args.say_rate or settings.say_rate,\n398\t                \"coqui_tts_platform\": settings.coqui_tts_platform,\n399\t                \"coqui_tts_image\": settings.coqui_tts_image,\n400\t                \"coqui_tts_model\": settings.coqui_tts_model,\n401\t                \"review_base_url\": settings.review_base_url,\n402\t            },\n403\t        )\n404\t    elif args.command == \"approve\":\n405\t        job = runner.approve(args.job_id, args.script, approved_by=args.approved_by)\n406\t    elif args.command == \"resume\":\n407\t        job = runner.resume(args.job_id)\n408\t    elif args.command == \"reject\":\n409\t        job = runner.reject(args.job_id, args.reason)\n410\t    elif args.command == \"log-touch\":\n411\t        job = runner.log_touch_time(args.job_id, args.minutes, args.note)\n412\t    else:\n413\t        raise AssertionError(args.command)\n414\t\n415\t    print(json.dumps({\"id\": job.id, \"status\": job.status, \"approval_status\": job.approval_status}, indent=2))\n416\t    return 0\n417\t\n418\t\n419\tdef _build_elevenlabs_client(\n420\t    args: argparse.Namespace,\n421\t    settings: Any,\n422\t    job_input: dict[str, Any] | None = None,\n423\t) -> Any | None:\n424\t    job_input = job_input or {}\n425\t    provider = getattr(args, \"tts_provider\", None) or job_input.get(\"local_tts_provider\")\n426\t    if provider != \"elevenlabs\":\n427\t        return None\n428\t    voice_id = getattr(args, \"voice_id\", None) or job_input.get(\"voice_id\") or settings.default_voice_id\n429\t    if not voice_id:\n430\t        raise SystemExit(\"--tts-provider elevenlabs requires --voice-id or DEFAULT_VOICE_ID in .env\")\n431\t    if not settings.elevenlabs_api_key:\n432\t        raise SystemExit(\"--tts-provider elevenlabs requires ELEVENLABS_API_KEY in .env\")\n433\t    voice_owner = getattr(args, \"voice_owner\", None) or job_input.get(\"voice_owner\") or settings.voice_clone_owner\n434\t    if not settings.voice_clone_consent_confirmed or not voice_owner:\n435\t        raise SystemExit(\n436\t            \"ElevenLabs cloned voice use requires recorded consent: VOICE_CLONE_CONSENT_CONFIRMED=true \"\n437\t            \"and VOICE_CLONE_OWNER (or --voice-owner)\"\n438\t        )\n439\t    import elevenlabs\n440\t    from core.integrations.elevenlabs_voice import ElevenLabsVoiceClient\n441\t    return ElevenLabsVoiceClient(elevenlabs.ElevenLabs(api_key=settings.elevenlabs_api_key), voice_id=voice_id)\n442\t\n443\t\n444\tdef _build_transcription_client(args: argparse.Namespace, settings: Any) -> Any | None:\n445\t    provider = getattr(args, \"transcription_provider\", \"none\")\n446\t    if provider == \"whisper\":\n447\t        import openai  # lazy import keeps offline tests clean\n448\t        from core.integrations.openai_whisper import OpenAIWhisperClient\n449\t        return OpenAIWhisperClient(openai.OpenAI(api_key=settings.openai_api_key))\n450\t    if provider == \"local-whisper\":\n451\t        from core.integrations.local_whisper import LocalWhisperClient\n452\t        return LocalWhisperClient()\n453\t    return None\n454\t\n455\t\n456\tdef _runner(\n457\t    workflow_name: str,\n458\t    store: JsonJobStore,\n459\t    transcription_client: Any = None,\n460\t    elevenlabs_client: Any = None,\n461\t) -> WorkflowRunner:\n462\t    config_path = Path(\"workflows\") / workflow_name / \"config.yaml\"\n463\t    modules = default_modules(tts_client=elevenlabs_client)\n464\t    if transcription_client is not None:\n465\t        modules[\"transcription\"] = LocalTranscriptionModule(transcription_client)\n466\t    return WorkflowRunner(load_workflow_config(config_path), modules, store)\n467\t\n468\t\n469\tif __name__ == \"__main__\":\n470\t    raise SystemExit(main())\n471\t"}]},"uuid":"f38855d3-1d58-401e-bfad-cc9397dcfeb7","timestamp":"2026-07-21T02:45:15.895Z","sourceToolAssistantUUID":"f07cbce7-ec5a-48bc-8e56-462dcd34aa19","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"f38855d3-1d58-401e-bfad-cc9397dcfeb7","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESF3CzAJ9T8YZ5yhhRx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KLGy6wscURuw1Voq5MuQty","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/runner.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":464,"cache_read_input_tokens":14161,"cache_creation":{"ephemeral_5m_input_tokens":464,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESF2Pd2R9mmoYePnZd4","attributionAgent":"Explore","type":"assistant","uuid":"a48b3719-332b-4118-8d2a-f41ad5d3719e","timestamp":"2026-07-21T02:45:16.115Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"a48b3719-332b-4118-8d2a-f41ad5d3719e","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KLGy6wscURuw1Voq5MuQty","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\tfrom dataclasses import dataclass\n4\tfrom pathlib import Path\n5\tfrom time import perf_counter\n6\tfrom typing import Protocol\n7\t\n8\tfrom core.models import ApprovalStatus, Job, JobStatus, JobStep, StepStatus\n9\tfrom core.workflows.config import WorkflowConfig\n10\tfrom core.workflows.store import JsonJobStore\n11\t\n12\t\n13\tclass WorkflowModule(Protocol):\n14\t    def run(self, context: \"WorkflowContext\", params: dict) -> dict:\n15\t        ...\n16\t\n17\t\n18\t@dataclass(slots=True)\n19\tclass WorkflowContext:\n20\t    job: Job\n21\t    workspace: Path\n22\t    data: dict\n23\t\n24\t\n25\tclass ApprovalGate:\n26\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n27\t        artifact = params.get(\"approval_artifact\", \"approved_script.md\")\n28\t        script = context.data.get(\"generated_script\", \"\")\n29\t        approval_path = context.workspace / artifact\n30\t        if not approval_path.exists():\n31\t            approval_path.parent.mkdir(parents=True, exist_ok=True)\n32\t            approval_path.write_text(script, encoding=\"utf-8\")\n33\t        return {\"approval_artifact\": str(approval_path)}\n34\t\n35\t\n36\tclass WorkflowRunner:\n37\t    def __init__(\n38\t        self,\n39\t        config: WorkflowConfig,\n40\t        modules: dict[str, WorkflowModule],\n41\t        store: JsonJobStore,\n42\t        output_root: str | Path = \"output\",\n43\t    ) -> None:\n44\t        self.config = config\n45\t        self.modules = modules\n46\t        self.store = store\n47\t        self.output_root = Path(output_root)\n48\t\n49\t    def start(self, tenant_id: str, input_params: dict) -> Job:\n50\t        job = Job(tenant_id=tenant_id, workflow_name=self.config.name, input_params=input_params)\n51\t        self.store.save(job)\n52\t        return self.run(job.id)\n53\t\n54\t    def run(self, job_id: str) -> Job:\n55\t        job = self.store.get(job_id)\n56\t        job.mark_started()\n57\t        context = WorkflowContext(\n58\t            job=job,\n59\t            workspace=self.output_root / job.id,\n60\t            data={**job.input_params, **job.output_params},\n61\t        )\n62\t        context.workspace.mkdir(parents=True, exist_ok=True)\n63\t\n64\t        completed_steps = {step.step_id for step in job.steps if step.status == StepStatus.COMPLETED}\n65\t        for workflow_step in self.config.steps:\n66\t            if workflow_step.id in completed_steps:\n67\t                continue\n68\t            module = self.modules[workflow_step.module]\n69\t            step = JobStep(step_id=workflow_step.id, status=StepStatus.RUNNING, input_data=workflow_step.params)\n70\t            job.steps.append(step)\n71\t            started = perf_counter()\n72\t            try:\n73\t                result = module.run(context, workflow_step.params)\n74\t            except Exception as exc:\n75\t                step.duration_ms = int((perf_counter() - started) * 1000)\n76\t                step.status = StepStatus.FAILED\n77\t                step.output_data = {\"error\": str(exc)}\n78\t                job.fail(str(exc))\n79\t                self.store.save(job)\n80\t                raise\n81\t            step.duration_ms = int((perf_counter() - started) * 1000)\n82\t            step.output_data = result\n83\t\n84\t            if workflow_step.module == \"approval_gate\" and job.approval_status != ApprovalStatus.APPROVED:\n85\t                step.status = StepStatus.WAITING_FOR_APPROVAL\n86\t                job.output_params.update(context.data)\n87\t                job.output_params.update(result)\n88\t                job.wait_for_approval()\n89\t                self.store.save(job)\n90\t                return job\n91\t\n92\t            context.data.update(result)\n93\t            step.status = StepStatus.COMPLETED\n94\t            job.output_params.update(context.data)\n95\t            self.store.save(job)\n96\t\n97\t        job.complete()\n98\t        self.store.save(job)\n99\t        return job\n100\t\n101\t    def approve(self, job_id: str, approved_script: str | Path, approved_by: str = \"operator\") -> Job:\n102\t        job = self.store.get(job_id)\n103\t        if job.approval_status != ApprovalStatus.PENDING:\n104\t            raise ValueError(f\"Job {job_id} is not waiting for approval\")\n105\t\n106\t        script_text = Path(approved_script).read_text(encoding=\"utf-8\")\n107\t        job.output_params[\"approved_script\"] = script_text\n108\t        job.approve()\n109\t        for step in job.steps:\n110\t            if step.status == StepStatus.WAITING_FOR_APPROVAL:\n111\t                step.status = StepStatus.COMPLETED\n112\t                step.approved_by = approved_by\n113\t                step.approved_at = job.approved_at\n114\t        self.store.save(job)\n115\t        return self.run(job.id)\n116\t\n117\t    def resume(self, job_id: str) -> Job:\n118\t        job = self.store.get(job_id)\n119\t        if job.status != JobStatus.FAILED or job.approval_status != ApprovalStatus.APPROVED:\n120\t            raise ValueError(f\"Job {job_id} is not an approved failed job\")\n121\t        return self.run(job_id)\n122\t\n123\t    def reject(self, job_id: str, reason: str) -> Job:\n124\t        job = self.store.get(job_id)\n125\t        job.reject(reason)\n126\t        self.store.save(job)\n127\t        return job\n128\t\n129\t    def log_touch_time(self, job_id: str, minutes: float, note: str = \"\") -> Job:\n130\t        if minutes <= 0:\n131\t            raise ValueError(\"Touch time minutes must be positive\")\n132\t        job = self.store.get(job_id)\n133\t        entries = list(job.output_params.get(\"manual_touch_log\", []))\n134\t        entries.append({\"minutes\": minutes, \"note\": note})\n135\t        job.output_params[\"manual_touch_log\"] = entries\n136\t        job.output_params[\"manual_touch_minutes\"] = sum(entry[\"minutes\"] for entry in entries)\n137\t        self.store.save(job)\n138\t        return job\n139\t"}]},"uuid":"d481e112-afbc-4154-954c-ead369fbd4de","timestamp":"2026-07-21T02:45:16.132Z","sourceToolAssistantUUID":"a48b3719-332b-4118-8d2a-f41ad5d3719e","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d481e112-afbc-4154-954c-ead369fbd4de","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESF3CzAJ9T8YZ5yhhRx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ra2k3regz2EGwPFtB38ytd","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/config.py"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":464,"cache_read_input_tokens":14161,"output_tokens":188,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":464},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":188,"cache_read_input_tokens":14161,"cache_creation_input_tokens":464,"cache_creation":{"ephemeral_5m_input_tokens":464,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESF2Pd2R9mmoYePnZd4","attributionAgent":"Explore","type":"assistant","uuid":"7d9e294e-c0e1-4203-8bc2-8d639f1ba56e","timestamp":"2026-07-21T02:45:16.433Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"7d9e294e-c0e1-4203-8bc2-8d639f1ba56e","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ra2k3regz2EGwPFtB38ytd","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\tfrom dataclasses import dataclass\n5\tfrom pathlib import Path\n6\tfrom typing import Any\n7\t\n8\t\n9\t@dataclass(frozen=True, slots=True)\n10\tclass WorkflowStep:\n11\t    id: str\n12\t    module: str\n13\t    params: dict[str, Any]\n14\t\n15\t\n16\t@dataclass(frozen=True, slots=True)\n17\tclass WorkflowConfig:\n18\t    name: str\n19\t    description: str\n20\t    steps: list[WorkflowStep]\n21\t\n22\t\n23\tdef load_workflow_config(path: str | Path) -> WorkflowConfig:\n24\t    config_path = Path(path)\n25\t    raw = config_path.read_text(encoding=\"utf-8\")\n26\t    data = _load_mapping(raw)\n27\t    steps = [\n28\t        WorkflowStep(id=step[\"id\"], module=step[\"module\"], params=step.get(\"params\", {}))\n29\t        for step in data[\"steps\"]\n30\t    ]\n31\t    return WorkflowConfig(\n32\t        name=data[\"name\"],\n33\t        description=data.get(\"description\", \"\"),\n34\t        steps=steps,\n35\t    )\n36\t\n37\t\n38\tdef _load_mapping(raw: str) -> dict[str, Any]:\n39\t    try:\n40\t        import yaml  # type: ignore\n41\t    except ModuleNotFoundError:\n42\t        return json.loads(raw)\n43\t    loaded = yaml.safe_load(raw)\n44\t    if not isinstance(loaded, dict):\n45\t        raise ValueError(\"Workflow config must be a mapping\")\n46\t    return loaded\n47\t"}]},"uuid":"56ce8cfc-f364-4e03-8494-1fb35ed0776e","timestamp":"2026-07-21T02:45:16.449Z","sourceToolAssistantUUID":"7d9e294e-c0e1-4203-8bc2-8d639f1ba56e","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"56ce8cfc-f364-4e03-8494-1fb35ed0776e","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFDgGxTkGp7q6sUe8f","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me look at the workflow files themselves:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":10258,"cache_read_input_tokens":14625,"cache_creation":{"ephemeral_5m_input_tokens":10258,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFCGwW1WP7QB24dxFe","attributionAgent":"Explore","type":"assistant","uuid":"8a43d1a6-960d-44a0-a1c7-f342268a6121","timestamp":"2026-07-21T02:45:18.481Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"8a43d1a6-960d-44a0-a1c7-f342268a6121","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFDgGxTkGp7q6sUe8f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PVL6aS5zLGXsRoVrdaXQAv","name":"Bash","input":{"command":"find /workspace/extra/projects/stagehand/workflows -type f -name \"*.yaml\" -o -name \"*.yml\""},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":10258,"cache_read_input_tokens":14625,"output_tokens":92,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":10258},"inference_geo":"","iterations":[{"input_tokens":7,"output_tokens":92,"cache_read_input_tokens":14625,"cache_creation_input_tokens":10258,"cache_creation":{"ephemeral_5m_input_tokens":10258,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESFCGwW1WP7QB24dxFe","attributionAgent":"Explore","type":"assistant","uuid":"ee0955c2-8bf4-4977-8752-12ad791719e9","timestamp":"2026-07-21T02:45:18.923Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"ee0955c2-8bf4-4977-8752-12ad791719e9","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PVL6aS5zLGXsRoVrdaXQAv","type":"tool_result","content":"/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml\n/workspace/extra/projects/stagehand/workflows/video_narration/config.yaml\n/workspace/extra/projects/stagehand/workflows/publish_instagram/config.yaml","is_error":false}]},"uuid":"b42fa3f6-ccf2-4e9f-b2df-fa9839b84407","timestamp":"2026-07-21T02:45:18.944Z","sourceToolAssistantUUID":"ee0955c2-8bf4-4977-8752-12ad791719e9","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"b42fa3f6-ccf2-4e9f-b2df-fa9839b84407","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFPxQYnFQwP27Ni5TU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BQVZHbVJxCMwret8Jr9t8i","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":168,"cache_read_input_tokens":24883,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"output_tokens":35,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFNz7krb7VfinCi1xb","attributionAgent":"Explore","type":"assistant","uuid":"4531ceae-abab-455c-bdae-d7128e3ce4c8","timestamp":"2026-07-21T02:45:20.862Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"4531ceae-abab-455c-bdae-d7128e3ce4c8","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BQVZHbVJxCMwret8Jr9t8i","type":"tool_result","content":"1\t{\n2\t  \"name\": \"listing_reel\",\n3\t  \"description\": \"Authorized listing photos to an approved narrated Instagram reel package\",\n4\t  \"steps\": [\n5\t    {\"id\": \"intake\", \"module\": \"listing_intake\", \"params\": {}},\n6\t    {\n7\t      \"id\": \"draft_review\",\n8\t      \"module\": \"listing_review_draft\",\n9\t      \"params\": {\"target_duration_seconds\": 30}\n10\t    },\n11\t    {\n12\t      \"id\": \"approve_listing_reel\",\n13\t      \"module\": \"approval_gate\",\n14\t      \"params\": {\"required\": true, \"approval_artifact\": \"approved_listing_reel.json\"}\n15\t    },\n16\t    {\"id\": \"validate_approval\", \"module\": \"listing_review_approval\", \"params\": {}},\n17\t    {\n18\t      \"id\": \"script_review\",\n19\t      \"module\": \"listing_script_review\",\n20\t      \"params\": {\"width\": 1080, \"height\": 1920, \"fps\": 60}\n21\t    },\n22\t    {\n23\t      \"id\": \"synthesize_voice\",\n24\t      \"module\": \"voice_synthesis\",\n25\t      \"params\": {\"reserved_outro_seconds\": 4.5}\n26\t    },\n27\t    {\"id\": \"build_captions\", \"module\": \"captions\", \"params\": {\"style\": \"modern_bold\"}},\n28\t    {\"id\": \"build_graphics\", \"module\": \"listing_graphics\", \"params\": {}},\n29\t    {\n30\t      \"id\": \"render_reel\",\n31\t      \"module\": \"listing_photo_reel\",\n32\t      \"params\": {\"width\": 1080, \"height\": 1920, \"fps\": 60, \"outro_duration_seconds\": 4.5}\n33\t    },\n34\t    {\"id\": \"video_review\", \"module\": \"listing_video_review\", \"params\": {}},\n35\t    {\"id\": \"build_review_artifacts\", \"module\": \"listing_review_artifacts\", \"params\": {}},\n36\t    {\"id\": \"package_instagram\", \"module\": \"instagram_package\", \"params\": {\"aspect_ratio\": \"9:16\"}}\n37\t  ]\n38\t}\n39\t"}]},"uuid":"035e3aa1-4220-4a8f-a3d2-6c1f8cacda76","timestamp":"2026-07-21T02:45:20.871Z","sourceToolAssistantUUID":"4531ceae-abab-455c-bdae-d7128e3ce4c8","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"035e3aa1-4220-4a8f-a3d2-6c1f8cacda76","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFPxQYnFQwP27Ni5TU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UEeyfLxM6cbwGahqHXLgzk","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/workflows/video_narration/config.yaml"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":168,"cache_read_input_tokens":24883,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"output_tokens":35,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFNz7krb7VfinCi1xb","attributionAgent":"Explore","type":"assistant","uuid":"69be2c2b-6186-4d73-b652-30199f080e0f","timestamp":"2026-07-21T02:45:21.042Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"69be2c2b-6186-4d73-b652-30199f080e0f","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UEeyfLxM6cbwGahqHXLgzk","type":"tool_result","content":"1\t{\n2\t  \"name\": \"video_narration\",\n3\t  \"description\": \"Raw video to approved narrated and subtitled social video\",\n4\t  \"steps\": [\n5\t    {\n6\t      \"id\": \"transcribe\",\n7\t      \"module\": \"transcription\",\n8\t      \"params\": {\n9\t        \"model\": \"whisper-1\"\n10\t      }\n11\t    },\n12\t    {\n13\t      \"id\": \"generate_script\",\n14\t      \"module\": \"llm_script\",\n15\t      \"params\": {\n16\t        \"prompt_template\": \"narration_script.md\",\n17\t        \"tone\": \"warm_conversational\",\n18\t        \"target_duration_seconds\": 60\n19\t      }\n20\t    },\n21\t    {\n22\t      \"id\": \"approve_script\",\n23\t      \"module\": \"approval_gate\",\n24\t      \"params\": {\n25\t        \"required\": true,\n26\t        \"approval_artifact\": \"approved_script.md\"\n27\t      }\n28\t    },\n29\t    {\n30\t      \"id\": \"synthesize_voice\",\n31\t      \"module\": \"voice_synthesis\",\n32\t      \"params\": {\n33\t        \"provider\": \"elevenlabs\",\n34\t        \"voice_id\": \"${tenant.voice_id}\"\n35\t      }\n36\t    },\n37\t    {\n38\t      \"id\": \"burn_captions\",\n39\t      \"module\": \"captions\",\n40\t      \"params\": {\n41\t        \"style\": \"modern_bold\"\n42\t      }\n43\t    },\n44\t    {\n45\t      \"id\": \"format_output\",\n46\t      \"module\": \"video_format\",\n47\t      \"params\": {\n48\t        \"aspect_ratio\": \"9:16\",\n49\t        \"max_file_size_mb\": 50\n50\t      }\n51\t    }\n52\t  ]\n53\t}\n54\t"}]},"uuid":"bf41a1b3-31b2-446c-9946-28716e17d094","timestamp":"2026-07-21T02:45:21.051Z","sourceToolAssistantUUID":"69be2c2b-6186-4d73-b652-30199f080e0f","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"bf41a1b3-31b2-446c-9946-28716e17d094","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFPxQYnFQwP27Ni5TU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VznevX4j1z9jvF3cjdhXtK","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/listings/review.py"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":168,"cache_read_input_tokens":24883,"output_tokens":182,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":168},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":182,"cache_read_input_tokens":24883,"cache_creation_input_tokens":168,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESFNz7krb7VfinCi1xb","attributionAgent":"Explore","type":"assistant","uuid":"a953d262-2f80-48a1-9e8d-a3d52ec765be","timestamp":"2026-07-21T02:45:21.441Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"a953d262-2f80-48a1-9e8d-a3d52ec765be","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VznevX4j1z9jvF3cjdhXtK","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport hashlib\n4\timport json\n5\timport math\n6\tfrom dataclasses import dataclass\n7\tfrom pathlib import Path\n8\tfrom typing import Any\n9\t\n10\tfrom core.listings.manifest import ListingManifestError, listing_snapshot_from_dict\n11\tfrom core.listings.models import AgentProfile, ListingPhoto, ListingSnapshot\n12\tfrom core.publishing.instagram import build_caption\n13\t\n14\t\n15\tclass ReviewBundleError(ValueError):\n16\t    pass\n17\t\n18\t\n19\t@dataclass(frozen=True, slots=True)\n20\tclass ApprovedListingReel:\n21\t    narration: str\n22\t    instagram_caption: str\n23\t    photos: tuple[ListingPhoto, ...]\n24\t    cover_photo: ListingPhoto\n25\t    outro: dict[str, Any]\n26\t    target_duration_seconds: float\n27\t\n28\t\n29\tdef snapshot_digest(snapshot: ListingSnapshot) -> str:\n30\t    canonical = json.dumps(snapshot.to_dict(), sort_keys=True, separators=(\",\", \":\"))\n31\t    return hashlib.sha256(canonical.encode(\"utf-8\")).hexdigest()\n32\t\n33\t\n34\tdef build_review_bundle(\n35\t    snapshot: ListingSnapshot,\n36\t    agent: AgentProfile,\n37\t    *,\n38\t    target_duration_seconds: float = 30,\n39\t) -> dict[str, Any]:\n40\t    narration = _draft_narration(snapshot, agent)\n41\t    cta = f\"Contact {agent.name} for details or a private tour.\"\n42\t    caption = build_caption(\n43\t        script=_listing_summary(snapshot),\n44\t        handle=agent.instagram_handle,\n45\t        cta=cta,\n46\t    )\n47\t    return {\n48\t        \"version\": 1,\n49\t        \"snapshot_sha256\": snapshot_digest(snapshot),\n50\t        \"listing\": snapshot.to_dict(),\n51\t        \"narration\": narration,\n52\t        \"instagram_caption\": caption,\n53\t        \"photo_order\": [photo.relative_path for photo in snapshot.photos],\n54\t        \"cover_photo_path\": snapshot.cover_photo.relative_path,\n55\t        \"outro\": {\n56\t            \"agent_name\": agent.name,\n57\t            \"cta\": \"Schedule a private tour\",\n58\t            \"instagram_handle\": agent.instagram_handle,\n59\t            \"phone\": agent.phone,\n60\t            \"email\": agent.email,\n61\t            \"website\": agent.website,\n62\t            \"profile_photo_path\": agent.profile_photo_path,\n63\t        },\n64\t        \"voice_owner\": agent.voice_owner or agent.name,\n65\t        \"target_duration_seconds\": float(target_duration_seconds),\n66\t    }\n67\t\n68\t\n69\tdef parse_approved_review(\n70\t    raw_text: str,\n71\t    snapshot: ListingSnapshot,\n72\t    *,\n73\t    media_root: str | Path,\n74\t) -> ApprovedListingReel:\n75\t    try:\n76\t        raw = json.loads(raw_text)\n77\t    except json.JSONDecodeError as exc:\n78\t        raise ReviewBundleError(\"Approved review must be valid JSON\") from exc\n79\t    if not isinstance(raw, dict) or raw.get(\"version\") != 1:\n80\t        raise ReviewBundleError(\"Approved review version must be 1\")\n81\t    if raw.get(\"snapshot_sha256\") != snapshot_digest(snapshot):\n82\t        raise ReviewBundleError(\"Approved review snapshot digest does not match intake\")\n83\t    try:\n84\t        listing_snapshot_from_dict(snapshot.to_dict(), media_root=media_root, verify_media=True)\n85\t    except (ListingManifestError, OSError) as exc:\n86\t        raise ReviewBundleError(str(exc)) from exc\n87\t\n88\t    narration = normalize_speech_text(str(raw.get(\"narration\") or \"\").strip())\n89\t    caption = str(raw.get(\"instagram_caption\") or \"\").strip()\n90\t    if not narration:\n91\t        raise ReviewBundleError(\"Approved review narration is required\")\n92\t    if not caption:\n93\t        raise ReviewBundleError(\"Approved review Instagram caption is required\")\n94\t\n95\t    order = raw.get(\"photo_order\")\n96\t    approved_paths = [photo.relative_path for photo in snapshot.photos]\n97\t    if not isinstance(order, list) or sorted(order) != sorted(approved_paths) or len(set(order)) != len(order):\n98\t        raise ReviewBundleError(\"Approved review photo_order must contain the approved photo set exactly once\")\n99\t    by_path = {photo.relative_path: photo for photo in snapshot.photos}\n100\t    cover_path = str(raw.get(\"cover_photo_path\") or \"\")\n101\t    if cover_path not in by_path:\n102\t        raise ReviewBundleError(\"Approved review cover_photo_path must reference an approved photo\")\n103\t\n104\t    outro = raw.get(\"outro\")\n105\t    if not isinstance(outro, dict) or not str(outro.get(\"agent_name\") or \"\").strip():\n106\t        raise ReviewBundleError(\"Approved review outro requires an agent name\")\n107\t    if not any(str(outro.get(key) or \"\").strip() for key in (\"instagram_handle\", \"phone\", \"email\", \"website\")):\n108\t        raise ReviewBundleError(\"Approved review outro requires at least one contact method\")\n109\t    try:\n110\t        duration = float(raw.get(\"target_duration_seconds\", 30))\n111\t    except (TypeError, ValueError) as exc:\n112\t        raise ReviewBundleError(\"Approved review target duration must be numeric\") from exc\n113\t    if not math.isfinite(duration) or not 20 <= duration <= 45:\n114\t        raise ReviewBundleError(\"Approved review target duration must be between 20 and 45 seconds\")\n115\t\n116\t    return ApprovedListingReel(\n117\t        narration=narration,\n118\t        instagram_caption=caption,\n119\t        photos=tuple(by_path[path] for path in order),\n120\t        cover_photo=by_path[cover_path],\n121\t        outro=dict(outro),\n122\t        target_duration_seconds=duration,\n123\t    )\n124\t\n125\t\n126\tdef normalize_speech_text(text: str) -> str:\n127\t    \"\"\"Expand abbreviations; strip bare street-type abbreviations for TTS.\"\"\"\n128\t    import re\n129\t\n130\t    value = str(text or \"\")\n131\t    if not value:\n132\t        return value\n133\t    value = re.sub(r\"(?i)\\bsq\\.?\\s*ft\\.?\\b\", \"square feet\", value)\n134\t    value = re.sub(r\"(?i)\\bsqft\\b\", \"square feet\", value)\n135\t    value = re.sub(r\"(?i)\\bsquare\\s*ft\\.?\\b\", \"square feet\", value)\n136\t    value = re.sub(r\"(?i)\\bapprox\\.?\\b\", \"approximately\", value)\n137\t    # Only abbreviated street suffixes after a word (so \"Dr\" never becomes \"doctor\").\n138\t    value = re.sub(\n139\t        r\"(?i)(?<=\\w)\\s+(st|dr|rd|ln|blvd|ave|ct|cir|pl|pkwy|hwy|ter)\\.?\\b\",\n140\t        \"\",\n141\t        value,\n142\t    )\n143\t    value = re.sub(r\"\\s+\", \" \", value).strip(\" ,\")\n144\t    value = re.sub(r\"\\s+([,.])\", r\"\\1\", value)\n145\t    return value\n146\t\n147\t\n148\tdef spoken_location(snapshot: ListingSnapshot) -> str:\n149\t    \"\"\"City / state only for spoken welcome.\n150\t\n151\t    Skip house numbers and street names — Captions/covers still show the full address.\n152\t    \"\"\"\n153\t    city = (snapshot.city or \"\").strip()\n154\t    state = (snapshot.state or \"\").strip()\n155\t    return \", \".join(part for part in (city, state) if part)\n156\t\n157\t\n158\tdef _draft_narration(snapshot: ListingSnapshot, agent: AgentProfile) -> str:\n159\t    import re\n160\t\n161\t    pieces = []\n162\t    place = spoken_location(snapshot)\n163\t    if place:\n164\t        pieces.append(f\"Welcome to this home in {place}.\")\n165\t    else:\n166\t        pieces.append(\"Welcome to this listing.\")\n167\t    room_parts = []\n168\t    if snapshot.beds is not None:\n169\t        room_parts.append(f\"{_number_word(snapshot.beds)}-bedroom\")\n170\t    if snapshot.baths is not None:\n171\t        room_parts.append(f\"{_number_word(snapshot.baths)}-bath\")\n172\t    if room_parts:\n173\t        sentence = f\"This {', '.join(room_parts)} home\"\n174\t        if snapshot.square_feet is not None:\n175\t            sentence += f\" offers {snapshot.square_feet:,} square feet\"\n176\t        pieces.append(sentence + \".\")\n177\t    elif snapshot.square_feet is not None:\n178\t        pieces.append(f\"The home offers {snapshot.square_feet:,} square feet.\")\n179\t    if snapshot.price is not None:\n180\t        pieces.append(f\"It is offered at ${snapshot.price:,}.\")\n181\t    if snapshot.description:\n182\t        description = normalize_speech_text(snapshot.description)\n183\t        first = re.split(r\"(?<=[.!?])\\s+\", description, maxsplit=1)[0].strip(\" .\")\n184\t        # Keep only short non-address marketing phrases; never second \"Welcome to 123...\".\n185\t        if (\n186\t            first\n187\t            and len(first) <= 90\n188\t            and not re.search(r\"\\d{3,}\", first)\n189\t            and not re.match(r\"(?i)^welcome to\\b\", first)\n190\t        ):\n191\t            if not first.endswith((\".\", \"!\", \"?\")):\n192\t                first += \".\"\n193\t            pieces.append(first)\n194\t    pieces.append(f\"Contact {agent.name} to schedule a private tour.\")\n195\t    return normalize_speech_text(\" \".join(pieces))\n196\t\n197\t\n198\tdef _listing_summary(snapshot: ListingSnapshot) -> str:\n199\t    facts = []\n200\t    if snapshot.display_location:\n201\t        facts.append(snapshot.display_location)\n202\t    if snapshot.price is not None:\n203\t        facts.append(f\"${snapshot.price:,}\")\n204\t    rooms = []\n205\t    if snapshot.beds is not None:\n206\t        rooms.append(f\"{snapshot.beds:g} bed\")\n207\t    if snapshot.baths is not None:\n208\t        rooms.append(f\"{snapshot.baths:g} bath\")\n209\t    if rooms:\n210\t        facts.append(\" · \".join(rooms))\n211\t    if snapshot.square_feet is not None:\n212\t        facts.append(f\"{snapshot.square_feet:,} sq ft\")\n213\t    if snapshot.description:\n214\t        facts.append(snapshot.description)\n215\t    return \" — \".join(facts)\n216\t\n217\t\n218\tdef _number_word(value: float) -> str:\n219\t    integer = int(value)\n220\t    if value == integer and 0 <= integer <= 10:\n221\t        return (\"zero\", \"one\", \"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\")[integer]\n222\t    return f\"{value:g}\"\n223\t"}]},"uuid":"2e91aab5-6bc0-4d35-acc2-f394671299a4","timestamp":"2026-07-21T02:45:21.460Z","sourceToolAssistantUUID":"a953d262-2f80-48a1-9e8d-a3d52ec765be","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"2e91aab5-6bc0-4d35-acc2-f394671299a4","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFaZdb5HbFGELvNLZ8","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check the modules.py file to understand how the listing reel rendering and video review work:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":4335,"cache_read_input_tokens":25051,"cache_creation":{"ephemeral_5m_input_tokens":4335,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFZfowX7dVL31k5bgb","attributionAgent":"Explore","type":"assistant","uuid":"4ca1616c-4ead-4f5b-a5c5-6446b9301434","timestamp":"2026-07-21T02:45:23.180Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"4ca1616c-4ead-4f5b-a5c5-6446b9301434","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFaZdb5HbFGELvNLZ8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018taQd1rKhQmwUP5Fn1MN7o","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/modules.py","limit":100},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":4335,"cache_read_input_tokens":25051,"output_tokens":110,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":4335},"inference_geo":"","iterations":[{"input_tokens":7,"output_tokens":110,"cache_read_input_tokens":25051,"cache_creation_input_tokens":4335,"cache_creation":{"ephemeral_5m_input_tokens":4335,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESFZfowX7dVL31k5bgb","attributionAgent":"Explore","type":"assistant","uuid":"d9b0f8f3-eeeb-4bb5-968b-ed2e23341bc0","timestamp":"2026-07-21T02:45:23.599Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d9b0f8f3-eeeb-4bb5-968b-ed2e23341bc0","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018taQd1rKhQmwUP5Fn1MN7o","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\timport re\n5\timport shutil\n6\timport subprocess\n7\tfrom collections import defaultdict\n8\tfrom pathlib import Path\n9\tfrom typing import Any\n10\t\n11\tfrom core.delivery_naming import (\n12\t    format_delivery_video_filename,\n13\t    resolve_delivery_content_type,\n14\t    resolve_delivery_title,\n15\t)\n16\tfrom core.media.caption_image import create_caption_image\n17\tfrom core.media.coqui_tts import coqui_is_available, create_coqui_audio\n18\tfrom core.media.ffmpeg_utils import (\n19\t    build_render_command,\n20\t    build_timed_audio_command,\n21\t    create_silent_audio,\n22\t    create_thumbnail,\n23\t    probe_media,\n24\t    probe_peak_volume_db,\n25\t    run_render_command,\n26\t)\n27\tfrom core.media.local_tts import LocalTtsError, create_say_audio, say_is_available\n28\tfrom core.publishing.instagram import build_caption, build_metadata\n29\tfrom core.workflows.runner import WorkflowContext\n30\t\n31\t_VIDEO_EXTENSIONS = {\".mov\", \".mp4\", \".mpeg\", \".mpg\", \".avi\", \".mkv\", \".webm\"}\n32\t\n33\t\n34\tclass LocalTranscriptionModule:\n35\t    def __init__(self, transcription_client: Any = None) -> None:\n36\t        self._client = transcription_client\n37\t\n38\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n39\t        input_path = context.data[\"input_path\"]\n40\t\n41\t        client = self._client or context.data.get(\"transcription_client\")\n42\t        if client is not None:\n43\t            audio_path = _audio_path_for_transcription(input_path, context.workspace)\n44\t            if hasattr(client, \"transcribe_with_segments\"):\n45\t                text, segments = client.transcribe_with_segments(audio_path)\n46\t                valid_segments = [s for s in segments if s[\"end\"] > s[\"start\"]]\n47\t                return {\"transcript\": text, \"transcript_segments\": valid_segments}\n48\t            return {\"transcript\": client.transcribe(audio_path)}\n49\t\n50\t        # Offline/demo fallback — explicit placeholder so it's obvious in logs\n51\t        transcript = context.data.get(\n52\t            \"transcript\",\n53\t            f\"[DEMO PLACEHOLDER — no transcription provider configured for {Path(input_path).name}]\",\n54\t        )\n55\t        return {\"transcript\": transcript}\n56\t\n57\t\n58\tclass LocalScriptModule:\n59\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n60\t        transcript = context.data.get(\"transcript\", \"\")\n61\t        listing_context = context.data.get(\"listing_description\") or context.data.get(\"listing_context\") or \"\"\n62\t        target_duration = context.data.get(\"target_duration_seconds\") or params.get(\"target_duration_seconds\", 60)\n63\t        script = _clean_transcript_for_narration(transcript)\n64\t        if listing_context:\n65\t            script = f\"{script}\\n\\nOperator context to consider during review: {listing_context}\".strip()\n66\t        return {\"generated_script\": script, \"target_duration_seconds\": target_duration}\n67\t\n68\t\n69\tclass LocalVoiceModule:\n70\t    def __init__(self, tts_client: Any = None) -> None:\n71\t        self._tts_client = tts_client\n72\t\n73\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n74\t        script = context.data.get(\"approved_narration\") or context.data.get(\"approved_script\", \"\")\n75\t        transcript_segments = context.data.get(\"transcript_segments\")\n76\t\n77\t        narration_text_path = context.workspace / \"narration.txt\"\n78\t        narration_text_path.write_text(script, encoding=\"utf-8\")\n79\t\n80\t        # Total duration: prefer input video duration so timed TTS fills the full video.\n81\t        target_duration = float(\n82\t            context.data.get(\"input_duration_seconds\")\n83\t            or context.data.get(\"target_duration_seconds\")\n84\t            or (transcript_segments[-1][\"end\"] + 2.0 if transcript_segments else None)\n85\t            or _estimate_narration_duration_seconds(script)\n86\t        )\n87\t        reserved_outro = float(\n88\t            context.data.get(\"reserved_outro_seconds\")\n89\t            or params.get(\"reserved_outro_seconds\", 0)\n90\t        )\n91\t        total_duration = max(1.0, target_duration - reserved_outro)\n92\t\n93\t        provider = context.data.get(\"local_tts_provider\", \"say\")\n94\t        require_audible = bool(context.data.get(\"require_audible_narration\", False))\n95\t        voice = str(context.data.get(\"say_voice\") or \"Samantha\")\n96\t        rate = int(context.data.get(\"say_rate\") or 200)\n97\t        el_client = self._tts_client\n98\t\n99\t        coqui_model = str(context.data.get(\"coqui_tts_model\") or \"tts_models/en/ljspeech/vits\")\n100\t        coqui_image = str(context.data.get(\"coqui_tts_image\") or \"ghcr.io/coqui-ai/tts-cpu\")"}]},"uuid":"896c8d34-d45e-4354-9a05-98ce5476a058","timestamp":"2026-07-21T02:45:23.615Z","sourceToolAssistantUUID":"d9b0f8f3-eeeb-4bb5-968b-ed2e23341bc0","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"896c8d34-d45e-4354-9a05-98ce5476a058","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFjio91bLWzEHnCMFb","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QU8TXS1aL5MMfRTojfrEzN","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/modules.py","offset":100,"limit":200},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":1673,"cache_read_input_tokens":29386,"output_tokens":104,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1673},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":104,"cache_read_input_tokens":29386,"cache_creation_input_tokens":1673,"cache_creation":{"ephemeral_5m_input_tokens":1673,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESFirTPdi4ngq7HLu6n","attributionAgent":"Explore","type":"assistant","uuid":"9d151b3e-1474-4d7c-be4e-a835f6460ce7","timestamp":"2026-07-21T02:45:25.353Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"9d151b3e-1474-4d7c-be4e-a835f6460ce7","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QU8TXS1aL5MMfRTojfrEzN","type":"tool_result","content":"100\t        coqui_image = str(context.data.get(\"coqui_tts_image\") or \"ghcr.io/coqui-ai/tts-cpu\")\n101\t        coqui_platform = str(context.data.get(\"coqui_tts_platform\") or \"linux/amd64\")\n102\t        tts_fn, clip_suffix, provider_name = _resolve_tts_fn(\n103\t            provider,\n104\t            voice,\n105\t            rate,\n106\t            el_client,\n107\t            coqui_model,\n108\t            coqui_image,\n109\t            coqui_platform,\n110\t        )\n111\t\n112\t        if transcript_segments and tts_fn is not None:\n113\t            # Timed TTS: probe actual video duration so speech lands at original timestamps.\n114\t            total_duration = float(\n115\t                context.data.get(\"input_duration_seconds\")\n116\t                or _probe_duration(context.data.get(\"input_path\"))\n117\t                or total_duration\n118\t            )\n119\t            tts_result = _build_timed_narration(\n120\t                script, transcript_segments, total_duration, context.workspace,\n121\t                tts_fn, clip_suffix, provider_name,\n122\t            )\n123\t        elif provider == \"elevenlabs\" and el_client is not None:\n124\t            narration_audio_path = context.workspace / \"narration.mp3\"\n125\t            try:\n126\t                el_client.synthesize_to_file(script, narration_audio_path)\n127\t                if not narration_audio_path.exists() or narration_audio_path.stat().st_size == 0:\n128\t                    raise RuntimeError(\"ElevenLabs returned empty narration audio\")\n129\t                total_duration = probe_media(narration_audio_path).duration_seconds\n130\t                tts_result = {\"narration_path\": str(narration_audio_path), \"voice_provider\": \"elevenlabs\"}\n131\t            except Exception as exc:\n132\t                if require_audible:\n133\t                    raise RuntimeError(\"Required audible narration could not be produced by ElevenLabs\") from exc\n134\t                narration_audio_path = context.workspace / \"narration.m4a\"\n135\t                create_silent_audio(narration_audio_path, total_duration)\n136\t                tts_result = {\n137\t                    \"narration_path\": str(narration_audio_path),\n138\t                    \"voice_provider\": \"elevenlabs_failed_silent_audio\",\n139\t                }\n140\t        elif provider == \"say\" and say_is_available():\n141\t            # Continuous TTS (no segment timestamps available).\n142\t            narration_audio_path = context.workspace / \"narration.aiff\"\n143\t            try:\n144\t                create_say_audio(script, narration_audio_path, voice=voice, rate=rate)\n145\t                if narration_audio_path.exists():\n146\t                    total_duration = probe_media(narration_audio_path).duration_seconds\n147\t                tts_result = {\"narration_path\": str(narration_audio_path), \"voice_provider\": \"local_say\"}\n148\t            except (LocalTtsError, subprocess.CalledProcessError):\n149\t                if require_audible:\n150\t                    raise RuntimeError(\"Required audible narration could not be produced by macOS say\")\n151\t                narration_audio_path = context.workspace / \"narration.m4a\"\n152\t                create_silent_audio(narration_audio_path, total_duration)\n153\t                tts_result = {\n154\t                    \"narration_path\": str(narration_audio_path),\n155\t                    \"voice_provider\": \"local_say_failed_silent_audio\",\n156\t                }\n157\t        elif provider == \"coqui\" and coqui_is_available():\n158\t            narration_audio_path = context.workspace / \"narration.wav\"\n159\t            try:\n160\t                create_coqui_audio(\n161\t                    script,\n162\t                    narration_audio_path,\n163\t                    model_name=coqui_model,\n164\t                    image=coqui_image,\n165\t                    platform=coqui_platform,\n166\t                )\n167\t                if narration_audio_path.exists():\n168\t                    total_duration = probe_media(narration_audio_path).duration_seconds\n169\t                tts_result = {\"narration_path\": str(narration_audio_path), \"voice_provider\": \"local_coqui\"}\n170\t            except Exception:\n171\t                if require_audible:\n172\t                    raise RuntimeError(\"Required audible narration could not be produced by Coqui\")\n173\t                narration_audio_path = context.workspace / \"narration.m4a\"\n174\t                create_silent_audio(narration_audio_path, total_duration)\n175\t                tts_result = {\n176\t                    \"narration_path\": str(narration_audio_path),\n177\t                    \"voice_provider\": \"local_coqui_failed_silent_audio\",\n178\t                }\n179\t        else:\n180\t            if require_audible:\n181\t                raise RuntimeError(f\"Required audible narration provider '{provider}' is unavailable\")\n182\t            narration_audio_path = context.workspace / \"narration.m4a\"\n183\t            create_silent_audio(narration_audio_path, total_duration)\n184\t            tts_result = {\"narration_path\": str(narration_audio_path), \"voice_provider\": \"local_stub_silent_audio\"}\n185\t\n186\t        if require_audible:\n187\t            try:\n188\t                peak_volume_db = probe_peak_volume_db(tts_result[\"narration_path\"])\n189\t            except Exception as exc:\n190\t                raise RuntimeError(\"Required audible narration could not be validated\") from exc\n191\t            if peak_volume_db < -60.0:\n192\t                raise RuntimeError(\n193\t                    f\"Required audible narration is effectively silent ({peak_volume_db:.1f} dB peak)\"\n194\t                )\n195\t\n196\t        return {\n197\t            **tts_result,\n198\t            \"narration_text_path\": str(narration_text_path),\n199\t            \"narration_duration_seconds\": total_duration,\n200\t        }\n201\t\n202\t\n203\tclass LocalCaptionModule:\n204\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n205\t        captions_path = context.workspace / \"captions.srt\"\n206\t        script = context.data.get(\"approved_narration\") or context.data.get(\"approved_script\", \"\")\n207\t        duration = float(\n208\t            context.data.get(\"narration_duration_seconds\")\n209\t            or context.data.get(\"target_duration_seconds\")\n210\t            or _estimate_narration_duration_seconds(script)\n211\t        )\n212\t        transcript_segments = context.data.get(\"transcript_segments\")\n213\t        if transcript_segments:\n214\t            segments = _caption_segments_from_whisper(script, transcript_segments, duration)\n215\t        else:\n216\t            segments = _caption_segments(script, duration)\n217\t        captions_path.write_text(_srt_from_segments(segments), encoding=\"utf-8\")\n218\t        overlay_dir = context.workspace / \"caption_overlays\"\n219\t        caption_overlays = []\n220\t        for index, segment in enumerate(segments, start=1):\n221\t            overlay_path = create_caption_image(segment[\"text\"], overlay_dir / f\"caption_{index:03}.png\")\n222\t            caption_overlays.append({**segment, \"path\": str(overlay_path)})\n223\t        return {\n224\t            \"captions_path\": str(captions_path),\n225\t            \"caption_overlays\": caption_overlays,\n226\t            \"caption_style\": params.get(\"style\", \"modern_bold\"),\n227\t        }\n228\t\n229\t\n230\tclass LocalRenderModule:\n231\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n232\t        output_path = context.workspace / \"final_video.mp4\"\n233\t        input_probe = probe_media(context.data[\"input_path\"])\n234\t        command = build_render_command(\n235\t            input_video=Path(context.data[\"input_path\"]),\n236\t            narration_audio=Path(context.data.get(\"narration_path\", context.workspace / \"narration.txt\")),\n237\t            captions=Path(context.data.get(\"captions_path\", context.workspace / \"captions.srt\")),\n238\t            output_video=output_path,\n239\t            aspect_ratio=params.get(\"aspect_ratio\", \"9:16\"),\n240\t            keep_original_audio=bool(context.data.get(\"keep_original_audio\", False)),\n241\t            caption_overlays=context.data.get(\"caption_overlays\"),\n242\t            output_duration_seconds=input_probe.duration_seconds,\n243\t        )\n244\t        command_path = context.workspace / \"render_command.txt\"\n245\t        command_path.write_text(\" \".join(command), encoding=\"utf-8\")\n246\t        result = {\"output_video\": str(output_path), \"render_command\": command, \"render_executed\": False}\n247\t        if bool(context.data.get(\"execute_render\", False)):\n248\t            run_render_command(command)\n249\t            probe = probe_media(output_path)\n250\t            result.update(\n251\t                {\n252\t                    \"render_executed\": True,\n253\t                    \"rendered_duration_seconds\": probe.duration_seconds,\n254\t                    \"rendered_width\": probe.width,\n255\t                    \"rendered_height\": probe.height,\n256\t                }\n257\t            )\n258\t        return result\n259\t\n260\t\n261\tclass InstagramCaptionModule:\n262\t    \"\"\"Draft an Instagram caption from the job's script/listing context.\n263\t\n264\t    Writes the draft into ``generated_script`` so the shared ApprovalGate\n265\t    surfaces it for human editing — nothing is packaged until an operator\n266\t    approves the caption.\n267\t    \"\"\"\n268\t\n269\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n270\t        listing = str(context.data.get(\"listing_description\") or \"\")\n271\t        # Prefer the narration/script as the hook; fall back to the listing copy.\n272\t        # When the listing is the only source, don't also append it as context\n273\t        # (that would duplicate the same sentence in the caption).\n274\t        script = (\n275\t            context.data.get(\"approved_caption\")\n276\t            or context.data.get(\"approved_script\")\n277\t            or context.data.get(\"narration\")\n278\t            or context.data.get(\"generated_script\")\n279\t            or listing\n280\t        )\n281\t        listing_context = listing if script and script != listing else \"\"\n282\t        caption = build_caption(\n283\t            script=script,\n284\t            handle=str(context.data.get(\"instagram_handle\") or \"\"),\n285\t            hashtags=context.data.get(\"instagram_hashtags\"),\n286\t            cta=str(context.data.get(\"instagram_cta\") or \"\"),\n287\t            listing_context=listing_context,\n288\t        )\n289\t        return {\"generated_script\": caption, \"caption_draft\": caption}\n290\t\n291\t\n292\tclass InstagramPackageModule:\n293\t    \"\"\"Assemble the manual-handoff package: video copy, caption, metadata, thumbnail.\"\"\"\n294\t\n295\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n296\t        aspect_ratio = params.get(\"aspect_ratio\", \"9:16\")\n297\t        publish_dir = context.workspace / \"publish\"\n298\t        publish_dir.mkdir(parents=True, exist_ok=True)\n299\t"}]},"uuid":"d271746f-f5df-4382-a409-8769bbe14076","timestamp":"2026-07-21T02:45:25.362Z","sourceToolAssistantUUID":"9d151b3e-1474-4d7c-be4e-a835f6460ce7","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d271746f-f5df-4382-a409-8769bbe14076","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFuPicEk6QXoRwNLWa","type":"message","role":"assistant","content":[{"type":"text","text":"Let me continue reading the modules and look for the listing-specific modules:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":3162,"cache_read_input_tokens":31059,"cache_creation":{"ephemeral_5m_input_tokens":3162,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFrTbtjrmeWnGxEHvW","attributionAgent":"Explore","type":"assistant","uuid":"1186afad-9b9f-464e-a96d-cbd15c756fcb","timestamp":"2026-07-21T02:45:27.471Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"1186afad-9b9f-464e-a96d-cbd15c756fcb","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESFuPicEk6QXoRwNLWa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BaxBbz95YC6eAuodBVedbF","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py","limit":150},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":3162,"cache_read_input_tokens":31059,"cache_creation":{"ephemeral_5m_input_tokens":3162,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESFrTbtjrmeWnGxEHvW","attributionAgent":"Explore","type":"assistant","uuid":"79bf6b1d-d96b-4d23-8431-6ac294267f34","timestamp":"2026-07-21T02:45:28.259Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"79bf6b1d-d96b-4d23-8431-6ac294267f34","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BaxBbz95YC6eAuodBVedbF","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\tfrom pathlib import Path\n5\tfrom typing import Any\n6\t\n7\tfrom core.listings.manifest import listing_snapshot_from_dict, load_listing_source, source_policy_for\n8\tfrom core.listings.models import AgentProfile\n9\tfrom core.listings.review import build_review_bundle, parse_approved_review, snapshot_digest\n10\tfrom core.listings.scorecard import (\n11\t    PROFILE_NAME,\n12\t    build_review_page,\n13\t    review_url_for,\n14\t    score_post_render,\n15\t    score_pre_render,\n16\t    write_score_report,\n17\t)\n18\tfrom core.media.ffmpeg_utils import probe_media\n19\tfrom core.media.listing_graphics import create_contact_outro, create_listing_cover\n20\tfrom core.media.photo_reel import build_photo_reel_command, run_photo_reel\n21\tfrom core.workflows.runner import WorkflowContext\n22\t\n23\t\n24\tclass ListingIntakeModule:\n25\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n26\t        listing_url = str(context.data.get(\"listing_url\") or \"\")\n27\t        manifest_path = context.data.get(\"listing_manifest_path\")\n28\t        default_root = (\n29\t            str(Path(str(manifest_path)).parent)\n30\t            if manifest_path\n31\t            else str(context.workspace / \"listing_media\")\n32\t        )\n33\t        media_root = str(context.data.get(\"listing_media_root\") or default_root)\n34\t        fetcher = None\n35\t        if not manifest_path:\n36\t            from core.listings.providers.registry import build_authorized_fetcher\n37\t\n38\t            Path(media_root).mkdir(parents=True, exist_ok=True)\n39\t            fetcher = build_authorized_fetcher(\n40\t                media_root=media_root,\n41\t                demo=bool(context.data.get(\"listing_demo_intake\")),\n42\t                rights_confirmed=bool(context.data.get(\"listing_rights_confirmed\")),\n43\t            )\n44\t        snapshot = load_listing_source(\n45\t            listing_url,\n46\t            manifest_path=manifest_path,\n47\t            media_root=media_root,\n48\t            fetcher=fetcher,\n49\t        )\n50\t        snapshot_path = context.workspace / \"listing_snapshot.json\"\n51\t        snapshot_path.write_text(json.dumps(snapshot.to_dict(), indent=2), encoding=\"utf-8\")\n52\t        return {\n53\t            \"listing_snapshot_path\": str(snapshot_path),\n54\t            \"listing_snapshot_sha256\": snapshot_digest(snapshot),\n55\t            \"listing_media_root\": media_root,\n56\t            \"listing_source_policy\": source_policy_for(listing_url).value,\n57\t            \"listing_source_provider\": snapshot.source_provider,\n58\t        }\n59\t\n60\t\n61\tclass ListingReviewDraftModule:\n62\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n63\t        snapshot = _load_snapshot(context)\n64\t        agent = AgentProfile.from_mapping(\n65\t            {\n66\t                \"name\": context.data.get(\"agent_name\"),\n67\t                \"instagram_handle\": context.data.get(\"agent_instagram_handle\"),\n68\t                \"phone\": context.data.get(\"agent_phone\"),\n69\t                \"email\": context.data.get(\"agent_email\"),\n70\t                \"website\": context.data.get(\"agent_website\"),\n71\t                \"voice_owner\": context.data.get(\"voice_owner\"),\n72\t                \"profile_photo_path\": context.data.get(\"agent_profile_photo\"),\n73\t            }\n74\t        )\n75\t        bundle = build_review_bundle(\n76\t            snapshot,\n77\t            agent,\n78\t            target_duration_seconds=float(\n79\t                context.data.get(\"target_duration_seconds\")\n80\t                or params.get(\"target_duration_seconds\", 30)\n81\t            ),\n82\t        )\n83\t        draft = json.dumps(bundle, indent=2)\n84\t        draft_path = context.workspace / \"listing_review_draft.json\"\n85\t        draft_path.write_text(draft, encoding=\"utf-8\")\n86\t        return {\n87\t            \"generated_script\": draft,\n88\t            \"listing_review_draft_path\": str(draft_path),\n89\t        }\n90\t\n91\t\n92\tclass ListingReviewApprovalModule:\n93\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n94\t        snapshot = _load_snapshot(context)\n95\t        approved = parse_approved_review(\n96\t            str(context.data.get(\"approved_script\") or \"\"),\n97\t            snapshot,\n98\t            media_root=context.data[\"listing_media_root\"],\n99\t        )\n100\t        approved_path = context.workspace / \"approved_listing_reel.normalized.json\"\n101\t        approved_payload = {\n102\t            \"narration\": approved.narration,\n103\t            \"instagram_caption\": approved.instagram_caption,\n104\t            \"photo_order\": [photo.relative_path for photo in approved.photos],\n105\t            \"cover_photo_path\": approved.cover_photo.relative_path,\n106\t            \"outro\": approved.outro,\n107\t            \"target_duration_seconds\": approved.target_duration_seconds,\n108\t        }\n109\t        approved_path.write_text(json.dumps(approved_payload, indent=2), encoding=\"utf-8\")\n110\t        return {\n111\t            \"approved_narration\": approved.narration,\n112\t            \"approved_caption\": approved.instagram_caption,\n113\t            \"approved_photo_paths\": [str(photo.path) for photo in approved.photos],\n114\t            \"approved_cover_photo_path\": str(approved.cover_photo.path),\n115\t            \"approved_outro\": approved.outro,\n116\t            \"target_duration_seconds\": approved.target_duration_seconds,\n117\t            \"approved_listing_reel_path\": str(approved_path),\n118\t        }\n119\t\n120\t\n121\tclass ListingGraphicsModule:\n122\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n123\t        snapshot = _load_snapshot(context)\n124\t        cover_source = Path(context.data[\"approved_cover_photo_path\"])\n125\t        cover_photo = next((photo for photo in snapshot.photos if photo.path == cover_source), None)\n126\t        if cover_photo is None:\n127\t            raise ValueError(\"Approved cover photo is not part of the listing snapshot\")\n128\t        fact_parts = []\n129\t        if snapshot.beds is not None:\n130\t            fact_parts.append(f\"{snapshot.beds:g} bed\")\n131\t        if snapshot.baths is not None:\n132\t            fact_parts.append(f\"{snapshot.baths:g} bath\")\n133\t        if snapshot.square_feet is not None:\n134\t            fact_parts.append(f\"{snapshot.square_feet:,} sq ft\")\n135\t        locality = \", \".join(part for part in (snapshot.city, snapshot.state) if part)\n136\t        title = snapshot.address or snapshot.display_location or \"Featured property\"\n137\t        subtitle_parts = ([] if title == locality else ([locality] if locality else [])) + fact_parts\n138\t        subtitle = \" · \".join(subtitle_parts)\n139\t        price = f\"${snapshot.price:,}\" if snapshot.price is not None else \"\"\n140\t        cover_path = create_listing_cover(\n141\t            cover_source,\n142\t            context.workspace / \"cover.jpg\",\n143\t            title=title,\n144\t            subtitle=subtitle,\n145\t            price=price,\n146\t            focal_point=cover_photo.focal_point,\n147\t        )\n148\t\n149\t        outro = dict(context.data.get(\"approved_outro\") or {})\n150\t        outro_path = create_contact_outro("}]},"uuid":"2c4a396e-5830-49e5-8185-89637f4dfc32","timestamp":"2026-07-21T02:45:28.305Z","sourceToolAssistantUUID":"79bf6b1d-d96b-4d23-8431-6ac294267f34","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"2c4a396e-5830-49e5-8185-89637f4dfc32","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESG6WCgkK9tfrjoQigV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GfjHJysezcRerAzSVJk6i2","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py","offset":150,"limit":150},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2179,"cache_read_input_tokens":34221,"output_tokens":109,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2179},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":109,"cache_read_input_tokens":34221,"cache_creation_input_tokens":2179,"cache_creation":{"ephemeral_5m_input_tokens":2179,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESG5VvrUsZYqfPW9Y9s","attributionAgent":"Explore","type":"assistant","uuid":"774e511f-5fd8-4119-8751-3e5ba381d0b9","timestamp":"2026-07-21T02:45:30.432Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"774e511f-5fd8-4119-8751-3e5ba381d0b9","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GfjHJysezcRerAzSVJk6i2","type":"tool_result","content":"150\t        outro_path = create_contact_outro(\n151\t            context.workspace / \"outro.png\",\n152\t            agent_name=str(outro.get(\"agent_name\") or \"\"),\n153\t            cta=str(outro.get(\"cta\") or \"Schedule a private tour\"),\n154\t            instagram_handle=str(outro.get(\"instagram_handle\") or \"\"),\n155\t            phone=str(outro.get(\"phone\") or \"\"),\n156\t            email=str(outro.get(\"email\") or \"\"),\n157\t            website=str(outro.get(\"website\") or \"\"),\n158\t            profile_photo_path=str(outro.get(\"profile_photo_path\") or \"\"),\n159\t        )\n160\t        return {\"cover_image_path\": str(cover_path), \"outro_image_path\": str(outro_path)}\n161\t\n162\t\n163\tclass ListingScriptReviewModule:\n164\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n165\t        snapshot = _load_snapshot(context)\n166\t        approved = _approved_reel(context)\n167\t        width = int(params.get(\"width\", 1080))\n168\t        height = int(params.get(\"height\", 1920))\n169\t        fps = int(params.get(\"fps\", 60))\n170\t        report = score_pre_render(snapshot, approved, width=width, height=height, fps=fps)\n171\t        qa_dir = context.workspace / \"qa\"\n172\t        report_path = write_score_report(qa_dir / \"script_review.json\", report)\n173\t        return {\n174\t            \"reel_profile\": PROFILE_NAME,\n175\t            \"script_review_path\": str(report_path),\n176\t            \"script_review\": report.to_dict(),\n177\t            \"pre_render_score_path\": str(report_path),\n178\t            \"pre_render_score\": report.to_dict(),\n179\t        }\n180\t\n181\t\n182\tclass ListingPhotoReelRenderModule:\n183\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n184\t        _load_snapshot(context)  # Re-verifies approved media hashes immediately before render.\n185\t        narration_duration = float(\n186\t            context.data.get(\"narration_duration_seconds\")\n187\t            or context.data.get(\"target_duration_seconds\")\n188\t            or 30\n189\t        )\n190\t        outro_duration = float(params.get(\"outro_duration_seconds\", 4.5))\n191\t        target_duration = float(context.data.get(\"target_duration_seconds\") or 30)\n192\t        total_duration = min(\n193\t            45.0,\n194\t            max(20.0, target_duration, narration_duration + outro_duration),\n195\t        )\n196\t        output_path = context.workspace / \"final_video.mp4\"\n197\t        command = build_photo_reel_command(\n198\t            photo_paths=context.data.get(\"approved_photo_paths\") or [],\n199\t            narration_audio=Path(context.data[\"narration_path\"]),\n200\t            outro_image=Path(context.data[\"outro_image_path\"]),\n201\t            caption_overlays=context.data.get(\"caption_overlays\") or [],\n202\t            output_video=output_path,\n203\t            total_duration_seconds=total_duration,\n204\t            outro_duration_seconds=outro_duration,\n205\t            width=int(params.get(\"width\", 1080)),\n206\t            height=int(params.get(\"height\", 1920)),\n207\t            fps=int(params.get(\"fps\", 60)),\n208\t        )\n209\t        command_path = context.workspace / \"render_command.txt\"\n210\t        command_path.write_text(\" \".join(command), encoding=\"utf-8\")\n211\t        result = {\n212\t            \"output_video\": str(output_path),\n213\t            \"render_command\": command,\n214\t            \"render_command_path\": str(command_path),\n215\t            \"render_executed\": False,\n216\t            \"output_duration_seconds\": total_duration,\n217\t        }\n218\t        if bool(context.data.get(\"execute_render\", False)):\n219\t            run_photo_reel(command)\n220\t            probe = probe_media(output_path)\n221\t            result.update(\n222\t                {\n223\t                    \"render_executed\": True,\n224\t                    \"rendered_duration_seconds\": probe.duration_seconds,\n225\t                    \"rendered_width\": probe.width,\n226\t                    \"rendered_height\": probe.height,\n227\t                    \"rendered_frame_rate\": probe.frame_rate,\n228\t                    \"rendered_video_codec\": probe.video_codec,\n229\t                    \"rendered_audio_codec\": probe.audio_codec,\n230\t                }\n231\t            )\n232\t        return result\n233\t\n234\t\n235\tclass ListingVideoReviewModule:\n236\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n237\t        snapshot = _load_snapshot(context)\n238\t        approved = _approved_reel(context)\n239\t        output_video = Path(context.data[\"output_video\"])\n240\t        probe = probe_media(output_video)\n241\t        has_profile_photo = bool(str((context.data.get(\"approved_outro\") or {}).get(\"profile_photo_path\") or \"\").strip())\n242\t        report = score_post_render(\n243\t            snapshot,\n244\t            approved,\n245\t            probe=probe,\n246\t            caption_overlay_count=len(context.data.get(\"caption_overlays\") or []),\n247\t            has_profile_photo=has_profile_photo,\n248\t        )\n249\t        qa_dir = context.workspace / \"qa\"\n250\t        report_path = write_score_report(qa_dir / \"video_review.json\", report)\n251\t        return {\n252\t            \"video_review_path\": str(report_path),\n253\t            \"video_review\": report.to_dict(),\n254\t            \"post_render_score_path\": str(report_path),\n255\t            \"post_render_score\": report.to_dict(),\n256\t        }\n257\t\n258\t\n259\tclass ListingReviewArtifactsModule:\n260\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n261\t        snapshot = _load_snapshot(context)\n262\t        publish_dir = context.workspace / \"publish\"\n263\t        publish_dir.mkdir(parents=True, exist_ok=True)\n264\t        review_base_url = str(context.data.get(\"review_base_url\") or \"\").strip()\n265\t        pre_report = context.data.get(\"script_review\") or context.data.get(\"pre_render_score\") or {}\n266\t        post_report = context.data.get(\"video_review\") or context.data.get(\"post_render_score\") or {}\n267\t\n268\t        pre_report_path = _copy_optional(\n269\t            Path(str(context.data.get(\"script_review_path\") or context.data.get(\"pre_render_score_path\") or \"\")),\n270\t            publish_dir / \"script_review.json\",\n271\t        )\n272\t        post_report_path = _copy_optional(\n273\t            Path(str(context.data.get(\"video_review_path\") or context.data.get(\"post_render_score_path\") or \"\")),\n274\t            publish_dir / \"video_review.json\",\n275\t        )\n276\t\n277\t        review_page = build_review_page(\n278\t            publish_dir / \"review.html\",\n279\t            title=str(snapshot.address or context.data.get(\"agent_name\") or \"Listing Reel Review\"),\n280\t            video_filename=\"final_video.mp4\",\n281\t            cover_filename=Path(str(context.data.get(\"cover_image_path\") or \"\")).name if context.data.get(\"cover_image_path\") else \"\",\n282\t            pre_report=_report_from_dict(pre_report) if pre_report else None,\n283\t            post_report=_report_from_dict(post_report) if post_report else None,\n284\t        )\n285\t        review_url = None\n286\t        if review_base_url:\n287\t            relative = review_page.relative_to(context.workspace).as_posix()\n288\t            review_url = review_url_for(review_base_url, relative)\n289\t        return {\n290\t            \"review_page_path\": str(review_page),\n291\t            \"review_page_url\": review_url,\n292\t            \"script_review_publish_path\": str(pre_report_path) if pre_report_path else None,\n293\t            \"video_review_publish_path\": str(post_report_path) if post_report_path else None,\n294\t            \"pre_render_score_publish_path\": str(pre_report_path) if pre_report_path else None,\n295\t            \"post_render_score_publish_path\": str(post_report_path) if post_report_path else None,\n296\t        }\n297\t\n298\t\n299\tListingPreRenderScoreModule = ListingScriptReviewModule"}]},"uuid":"2f5c6a7b-f900-417b-b244-a3f4f7bc96e7","timestamp":"2026-07-21T02:45:30.444Z","sourceToolAssistantUUID":"774e511f-5fd8-4119-8751-3e5ba381d0b9","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"2f5c6a7b-f900-417b-b244-a3f4f7bc96e7","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGFUCtWCfbyRJpvsCA","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check the scorecard module to understand how QA scoring works:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2468,"cache_read_input_tokens":36400,"cache_creation":{"ephemeral_5m_input_tokens":2468,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESGEWAQyPMWdu8dZvZW","attributionAgent":"Explore","type":"assistant","uuid":"e63320c6-582d-44d1-8c6b-2452382e767d","timestamp":"2026-07-21T02:45:32.008Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"e63320c6-582d-44d1-8c6b-2452382e767d","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGFUCtWCfbyRJpvsCA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017BAhMAP1g1KHoSt2gwjGw4","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/listings/scorecard.py","limit":150},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2468,"cache_read_input_tokens":36400,"output_tokens":105,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2468},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":105,"cache_read_input_tokens":36400,"cache_creation_input_tokens":2468,"cache_creation":{"ephemeral_5m_input_tokens":2468,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESGEWAQyPMWdu8dZvZW","attributionAgent":"Explore","type":"assistant","uuid":"077a3291-ad81-458f-a630-39727e735e91","timestamp":"2026-07-21T02:45:32.526Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"077a3291-ad81-458f-a630-39727e735e91","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017BAhMAP1g1KHoSt2gwjGw4","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\tfrom dataclasses import asdict, dataclass, field\n5\tfrom html import escape\n6\tfrom pathlib import Path\n7\tfrom typing import Any\n8\t\n9\tfrom core.listings.models import ListingSnapshot\n10\tfrom core.listings.review import ApprovedListingReel\n11\tfrom core.models import MediaProbe\n12\t\n13\tPROFILE_NAME = \"listing_reel_instagram\"\n14\tSCORECARD_VERSION = \"2026-07-11.v1\"\n15\tPROFILE_WEIGHTS = {\n16\t    \"format_and_delivery\": 15,\n17\t    \"visual_composition\": 25,\n18\t    \"motion_and_transitions\": 15,\n19\t    \"narration_scene_alignment\": 20,\n20\t    \"caption_alignment_readability\": 10,\n21\t    \"branding_outro_quality\": 10,\n22\t    \"technical_polish\": 5,\n23\t}\n24\t\n25\t\n26\t@dataclass(slots=True)\n27\tclass ScoreIssue:\n28\t    code: str\n29\t    severity: str\n30\t    message: str\n31\t    evidence: dict[str, Any] = field(default_factory=dict)\n32\t\n33\t\n34\t@dataclass(slots=True)\n35\tclass ScoreReport:\n36\t    profile: str\n37\t    version: str\n38\t    phase: str\n39\t    overall_score: int\n40\t    subscores: dict[str, int]\n41\t    hard_gate_warnings: list[dict[str, Any]]\n42\t    top_issues: list[dict[str, Any]]\n43\t    deterministic_checks: dict[str, Any]\n44\t    model_assisted_checks: dict[str, Any]\n45\t    recommendation: str\n46\t    confidence_note: str\n47\t    evidence: dict[str, Any]\n48\t    remote_review_url: str | None = None\n49\t\n50\t    def to_dict(self) -> dict[str, Any]:\n51\t        return {\n52\t            \"profile\": self.profile,\n53\t            \"version\": self.version,\n54\t            \"phase\": self.phase,\n55\t            \"overall_score\": self.overall_score,\n56\t            \"subscores\": dict(self.subscores),\n57\t            \"hard_gate_warnings\": list(self.hard_gate_warnings),\n58\t            \"top_issues\": list(self.top_issues),\n59\t            \"deterministic_checks\": dict(self.deterministic_checks),\n60\t            \"model_assisted_checks\": dict(self.model_assisted_checks),\n61\t            \"recommendation\": self.recommendation,\n62\t            \"confidence_note\": self.confidence_note,\n63\t            \"evidence\": dict(self.evidence),\n64\t            \"remote_review_url\": self.remote_review_url,\n65\t        }\n66\t\n67\t\n68\tdef score_pre_render(\n69\t    snapshot: ListingSnapshot,\n70\t    approved: ApprovedListingReel,\n71\t    *,\n72\t    width: int,\n73\t    height: int,\n74\t    fps: int,\n75\t) -> ScoreReport:\n76\t    warnings: list[ScoreIssue] = []\n77\t    issues: list[ScoreIssue] = []\n78\t    contact = approved.outro\n79\t    profile_photo_path = str(contact.get(\"profile_photo_path\") or \"\").strip()\n80\t    has_profile_photo = bool(profile_photo_path)\n81\t    if not has_profile_photo:\n82\t        warnings.append(\n83\t            ScoreIssue(\n84\t                code=\"missing_profile_photo\",\n85\t                severity=\"warning\",\n86\t                message=\"Agent-card outro does not include a profile photo.\",\n87\t                evidence={\"field\": \"outro.profile_photo_path\"},\n88\t            )\n89\t        )\n90\t    if len(approved.photos) < 3:\n91\t        issues.append(\n92\t            ScoreIssue(\n93\t                code=\"low_photo_count\",\n94\t                severity=\"warning\",\n95\t                message=\"Low photo count may reduce full-bleed variety and scene support.\",\n96\t                evidence={\"photo_count\": len(approved.photos)},\n97\t            )\n98\t        )\n99\t    portrait = height > width\n100\t    if not portrait:\n101\t        warnings.append(\n102\t            ScoreIssue(\n103\t                code=\"non_portrait_layout\",\n104\t                severity=\"warning\",\n105\t                message=\"Configured reel output is not portrait.\",\n106\t                evidence={\"width\": width, \"height\": height},\n107\t            )\n108\t        )\n109\t\n110\t    subscores = {\n111\t        \"format_and_delivery\": _clamp(100 if portrait else 40),\n112\t        \"visual_composition\": _clamp(65 + min(25, len(approved.photos) * 6) + (5 if snapshot.cover_photo_index == 0 else 0)),\n113\t        \"motion_and_transitions\": _clamp(85 if fps >= 60 else 70),\n114\t        \"narration_scene_alignment\": _pre_alignment_score(snapshot, approved),\n115\t        \"caption_alignment_readability\": 75,\n116\t        \"branding_outro_quality\": _clamp(90 if has_profile_photo else 62),\n117\t        \"technical_polish\": 90,\n118\t    }\n119\t    if warnings:\n120\t        subscores[\"branding_outro_quality\"] = min(subscores[\"branding_outro_quality\"], 62)\n121\t    if len(approved.photos) < 3:\n122\t        subscores[\"visual_composition\"] = min(subscores[\"visual_composition\"], 68)\n123\t\n124\t    return _build_report(\n125\t        phase=\"script_review\",\n126\t        subscores=subscores,\n127\t        warnings=warnings,\n128\t        issues=issues,\n129\t        deterministic_checks={\n130\t            \"portrait_expected\": True,\n131\t            \"configured_width\": width,\n132\t            \"configured_height\": height,\n133\t            \"configured_fps\": fps,\n134\t            \"photo_count\": len(approved.photos),\n135\t            \"has_profile_photo\": has_profile_photo,\n136\t            \"target_duration_seconds\": approved.target_duration_seconds,\n137\t        },\n138\t        model_assisted_checks=_pending_model_assisted(),\n139\t        evidence={\n140\t            \"cover_photo_path\": approved.cover_photo.relative_path,\n141\t            \"photo_order\": [photo.relative_path for photo in approved.photos],\n142\t        },\n143\t    )\n144\t\n145\t\n146\tdef score_post_render(\n147\t    snapshot: ListingSnapshot,\n148\t    approved: ApprovedListingReel,\n149\t    *,\n150\t    probe: MediaProbe,"}]},"uuid":"dffd8a64-6d19-4ea6-a22a-2c37da96806c","timestamp":"2026-07-21T02:45:32.539Z","sourceToolAssistantUUID":"077a3291-ad81-458f-a630-39727e735e91","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"dffd8a64-6d19-4ea6-a22a-2c37da96806c","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGQfbbZvnboQHy3ni8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AKLcGmzZopBqkfvzfzQmcw","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/listings/scorecard.py","offset":150,"limit":150},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1857,"cache_read_input_tokens":38868,"output_tokens":105,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1857},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":105,"cache_read_input_tokens":38868,"cache_creation_input_tokens":1857,"cache_creation":{"ephemeral_5m_input_tokens":1857,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESGPnGLg2x6NsYR8yET","attributionAgent":"Explore","type":"assistant","uuid":"852ab09e-0d49-4e9c-8e01-c06e716469fc","timestamp":"2026-07-21T02:45:34.460Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"852ab09e-0d49-4e9c-8e01-c06e716469fc","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AKLcGmzZopBqkfvzfzQmcw","type":"tool_result","content":"150\t    probe: MediaProbe,\n151\t    caption_overlay_count: int,\n152\t    has_profile_photo: bool,\n153\t) -> ScoreReport:\n154\t    warnings: list[ScoreIssue] = []\n155\t    issues: list[ScoreIssue] = []\n156\t    portrait = (probe.height or 0) > (probe.width or 0)\n157\t    if not portrait:\n158\t        warnings.append(\n159\t            ScoreIssue(\n160\t                code=\"artifact_not_portrait\",\n161\t                severity=\"warning\",\n162\t                message=\"Rendered artifact is not portrait.\",\n163\t                evidence={\"width\": probe.width, \"height\": probe.height},\n164\t            )\n165\t        )\n166\t    if not probe.has_audio:\n167\t        warnings.append(\n168\t            ScoreIssue(\n169\t                code=\"missing_audio_stream\",\n170\t                severity=\"warning\",\n171\t                message=\"Rendered artifact is missing an audio stream.\",\n172\t            )\n173\t        )\n174\t    if probe.duration_seconds < 20 or probe.duration_seconds > 45:\n175\t        issues.append(\n176\t            ScoreIssue(\n177\t                code=\"duration_out_of_band\",\n178\t                severity=\"warning\",\n179\t                message=\"Rendered reel duration falls outside the preferred 20–45 second range.\",\n180\t                evidence={\"duration_seconds\": probe.duration_seconds},\n181\t            )\n182\t        )\n183\t    if not has_profile_photo:\n184\t        warnings.append(\n185\t            ScoreIssue(\n186\t                code=\"missing_profile_photo\",\n187\t                severity=\"warning\",\n188\t                message=\"Rendered reel relies on an outro without a configured profile photo.\",\n189\t            )\n190\t        )\n191\t    duration_delta = abs(probe.duration_seconds - approved.target_duration_seconds)\n192\t    format_score = 100\n193\t    if not portrait:\n194\t        format_score -= 45\n195\t    if duration_delta > 5:\n196\t        format_score -= min(25, int(duration_delta * 2))\n197\t    elif duration_delta > 2:\n198\t        format_score -= 10\n199\t\n200\t    subscores = {\n201\t        \"format_and_delivery\": _clamp(format_score),\n202\t        \"visual_composition\": _clamp(72 + min(18, len(approved.photos) * 4)),\n203\t        \"motion_and_transitions\": _clamp(88 if (probe.frame_rate or 0) >= 59 else 72),\n204\t        \"narration_scene_alignment\": _post_alignment_score(snapshot, approved),\n205\t        \"caption_alignment_readability\": _clamp(84 if caption_overlay_count else 50),\n206\t        \"branding_outro_quality\": _clamp(92 if has_profile_photo else 62),\n207\t        \"technical_polish\": _technical_score(probe),\n208\t    }\n209\t    if not probe.has_audio:\n210\t        subscores[\"technical_polish\"] = min(subscores[\"technical_polish\"], 35)\n211\t        subscores[\"caption_alignment_readability\"] = min(subscores[\"caption_alignment_readability\"], 60)\n212\t\n213\t    return _build_report(\n214\t        phase=\"video_review\",\n215\t        subscores=subscores,\n216\t        warnings=warnings,\n217\t        issues=issues,\n218\t        deterministic_checks={\n219\t            \"artifact_width\": probe.width,\n220\t            \"artifact_height\": probe.height,\n221\t            \"artifact_duration_seconds\": probe.duration_seconds,\n222\t            \"artifact_frame_rate\": probe.frame_rate,\n223\t            \"artifact_video_codec\": probe.video_codec,\n224\t            \"artifact_audio_codec\": probe.audio_codec,\n225\t            \"artifact_has_audio\": probe.has_audio,\n226\t            \"caption_overlay_count\": caption_overlay_count,\n227\t            \"has_profile_photo\": has_profile_photo,\n228\t        },\n229\t        model_assisted_checks=_pending_model_assisted(),\n230\t        evidence={\"target_duration_seconds\": approved.target_duration_seconds},\n231\t    )\n232\t\n233\t\n234\tdef write_score_report(path: str | Path, report: ScoreReport) -> Path:\n235\t    target = Path(path)\n236\t    target.parent.mkdir(parents=True, exist_ok=True)\n237\t    target.write_text(json.dumps(report.to_dict(), indent=2), encoding=\"utf-8\")\n238\t    return target\n239\t\n240\t\n241\tdef build_review_page(\n242\t    path: str | Path,\n243\t    *,\n244\t    title: str,\n245\t    video_filename: str,\n246\t    cover_filename: str = \"\",\n247\t    pre_report: ScoreReport | None = None,\n248\t    post_report: ScoreReport | None = None,\n249\t    caption_filename: str = \"caption.txt\",\n250\t    metadata_filename: str = \"metadata.json\",\n251\t) -> Path:\n252\t    target = Path(path)\n253\t    target.parent.mkdir(parents=True, exist_ok=True)\n254\t    pre_html = _report_html(pre_report, \"Script Review\") if pre_report else \"\"\n255\t    post_html = _report_html(post_report, \"Video Review\") if post_report else \"\"\n256\t    cover_html = (\n257\t        f'<img src=\"{escape(cover_filename)}\" alt=\"Cover image\" style=\"max-width:220px;border-radius:16px;border:1px solid rgba(255,255,255,.16)\" />'\n258\t        if cover_filename\n259\t        else \"\"\n260\t    )\n261\t    html = f\"\"\"<!doctype html>\n262\t<html lang=\"en\">\n263\t<head>\n264\t  <meta charset=\"utf-8\" />\n265\t  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n266\t  <title>{escape(title)} — Reel Review</title>\n267\t  <style>\n268\t    body {{ font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; margin: 0; background:#0d1418; color:#f7f3e9; }}\n269\t    main {{ max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; }}\n270\t    .card {{ background:#131d22; border:1px solid rgba(255,255,255,.08); border-radius:20px; padding:20px; margin-top:20px; }}\n271\t    .muted {{ color:#c8d0d0; }}\n272\t    .score {{ font-size:40px; font-weight:700; }}\n273\t    video {{ width:100%; border-radius:16px; background:#000; }}\n274\t    ul {{ padding-left: 20px; }}\n275\t    a {{ color:#f0c26b; }}\n276\t    table {{ width:100%; border-collapse: collapse; }}\n277\t    td {{ padding:8px 0; border-bottom:1px solid rgba(255,255,255,.08); }}\n278\t  </style>\n279\t</head>\n280\t<body>\n281\t<main>\n282\t  <h1>{escape(title)}</h1>\n283\t  <p class=\"muted\">Private operator review surface for the rendered reel and QA scorecards.</p>\n284\t  <div class=\"card\">\n285\t    <video controls playsinline preload=\"metadata\" src=\"{escape(video_filename)}\"></video>\n286\t  </div>\n287\t  <div class=\"card\">\n288\t    <h2>Artifacts</h2>\n289\t    <p><a href=\"{escape(video_filename)}\">Open video</a> · <a href=\"{escape(caption_filename)}\">Open caption</a> · <a href=\"{escape(metadata_filename)}\">Open metadata</a></p>\n290\t    {cover_html}\n291\t  </div>\n292\t  {pre_html}\n293\t  {post_html}\n294\t</main>\n295\t</body>\n296\t</html>\n297\t\"\"\"\n298\t    target.write_text(html, encoding=\"utf-8\")\n299\t    return target"}]},"uuid":"f279a471-3e87-41d9-b4ff-a278493aebcb","timestamp":"2026-07-21T02:45:34.475Z","sourceToolAssistantUUID":"852ab09e-0d49-4e9c-8e01-c06e716469fc","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"f279a471-3e87-41d9-b4ff-a278493aebcb","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGYpVGxeSDinWrTg8c","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check the models to understand the Job and approval statuses:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2238,"cache_read_input_tokens":40725,"cache_creation":{"ephemeral_5m_input_tokens":2238,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESGXStdoWKuDG2jyqyE","attributionAgent":"Explore","type":"assistant","uuid":"93064909-14b6-42c4-92ee-a7c70c3cdbfa","timestamp":"2026-07-21T02:45:36.150Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"93064909-14b6-42c4-92ee-a7c70c3cdbfa","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGYpVGxeSDinWrTg8c","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NVT3bywwMR2GN6cCLQeH9c","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/models.py"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2238,"cache_read_input_tokens":40725,"output_tokens":84,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2238},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":84,"cache_read_input_tokens":40725,"cache_creation_input_tokens":2238,"cache_creation":{"ephemeral_5m_input_tokens":2238,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESGXStdoWKuDG2jyqyE","attributionAgent":"Explore","type":"assistant","uuid":"d5192139-281e-4034-af90-b18aaff88ebd","timestamp":"2026-07-21T02:45:36.464Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d5192139-281e-4034-af90-b18aaff88ebd","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NVT3bywwMR2GN6cCLQeH9c","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\tfrom dataclasses import dataclass, field\n4\tfrom datetime import UTC, datetime\n5\tfrom enum import StrEnum\n6\tfrom pathlib import Path\n7\tfrom typing import Any\n8\tfrom uuid import uuid4\n9\t\n10\t\n11\tclass JobStatus(StrEnum):\n12\t    PENDING = \"pending\"\n13\t    RUNNING = \"running\"\n14\t    WAITING_FOR_APPROVAL = \"waiting_for_approval\"\n15\t    COMPLETED = \"completed\"\n16\t    FAILED = \"failed\"\n17\t\n18\t\n19\tclass ApprovalStatus(StrEnum):\n20\t    NOT_REQUIRED = \"not_required\"\n21\t    PENDING = \"pending\"\n22\t    APPROVED = \"approved\"\n23\t    REJECTED = \"rejected\"\n24\t\n25\t\n26\tclass StepStatus(StrEnum):\n27\t    PENDING = \"pending\"\n28\t    RUNNING = \"running\"\n29\t    COMPLETED = \"completed\"\n30\t    WAITING_FOR_APPROVAL = \"waiting_for_approval\"\n31\t    FAILED = \"failed\"\n32\t    SKIPPED = \"skipped\"\n33\t\n34\t\n35\t@dataclass(slots=True)\n36\tclass Tenant:\n37\t    id: str\n38\t    name: str\n39\t    industry: str | None = None\n40\t    config: dict[str, Any] = field(default_factory=dict)\n41\t\n42\t\n43\t@dataclass(slots=True)\n44\tclass JobStep:\n45\t    step_id: str\n46\t    status: StepStatus = StepStatus.PENDING\n47\t    input_data: dict[str, Any] = field(default_factory=dict)\n48\t    output_data: dict[str, Any] = field(default_factory=dict)\n49\t    approved_by: str | None = None\n50\t    approved_at: str | None = None\n51\t    duration_ms: int | None = None\n52\t\n53\t\n54\t@dataclass(slots=True)\n55\tclass Job:\n56\t    tenant_id: str\n57\t    workflow_name: str\n58\t    input_params: dict[str, Any]\n59\t    id: str = field(default_factory=lambda: str(uuid4()))\n60\t    status: JobStatus = JobStatus.PENDING\n61\t    output_params: dict[str, Any] = field(default_factory=dict)\n62\t    approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED\n63\t    approved_at: str | None = None\n64\t    error_message: str | None = None\n65\t    started_at: str | None = None\n66\t    completed_at: str | None = None\n67\t    steps: list[JobStep] = field(default_factory=list)\n68\t\n69\t    def mark_started(self) -> None:\n70\t        self.status = JobStatus.RUNNING\n71\t        self.started_at = _now_iso()\n72\t        self.error_message = None\n73\t        self.completed_at = None\n74\t\n75\t    def wait_for_approval(self) -> None:\n76\t        self.status = JobStatus.WAITING_FOR_APPROVAL\n77\t        self.approval_status = ApprovalStatus.PENDING\n78\t\n79\t    def approve(self) -> None:\n80\t        self.status = JobStatus.PENDING\n81\t        self.approval_status = ApprovalStatus.APPROVED\n82\t        self.approved_at = _now_iso()\n83\t\n84\t    def reject(self, reason: str) -> None:\n85\t        self.status = JobStatus.FAILED\n86\t        self.approval_status = ApprovalStatus.REJECTED\n87\t        self.error_message = reason\n88\t        self.completed_at = _now_iso()\n89\t\n90\t    def complete(self) -> None:\n91\t        self.status = JobStatus.COMPLETED\n92\t        self.completed_at = _now_iso()\n93\t\n94\t    def fail(self, reason: str) -> None:\n95\t        self.status = JobStatus.FAILED\n96\t        self.error_message = reason\n97\t        self.completed_at = _now_iso()\n98\t\n99\t\n100\tdef _now_iso() -> str:\n101\t    return datetime.now(UTC).isoformat()\n102\t\n103\t\n104\t@dataclass(frozen=True, slots=True)\n105\tclass MediaProbe:\n106\t    path: Path\n107\t    duration_seconds: float\n108\t    width: int | None\n109\t    height: int | None\n110\t    has_audio: bool\n111\t    frame_rate: float | None = None\n112\t    video_codec: str | None = None\n113\t    audio_codec: str | None = None\n114\t    pixel_format: str | None = None\n115\t    color_range: str | None = None\n116\t"}]},"uuid":"3955244d-614f-4a9f-8e54-34376aea801d","timestamp":"2026-07-21T02:45:36.473Z","sourceToolAssistantUUID":"d5192139-281e-4034-af90-b18aaff88ebd","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"3955244d-614f-4a9f-8e54-34376aea801d","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGhEkNjX5XCf9xqvcw","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check how the hyperframes review works:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1329,"cache_read_input_tokens":42963,"cache_creation":{"ephemeral_5m_input_tokens":1329,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESGg82obLbAVKSFigk9","attributionAgent":"Explore","type":"assistant","uuid":"1bd42f62-28bf-4ce3-a205-de9d6fefd961","timestamp":"2026-07-21T02:45:38.692Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"1bd42f62-28bf-4ce3-a205-de9d6fefd961","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGhEkNjX5XCf9xqvcw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013YuhWRBUU6cFHcLrAUgQY8","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/hyperframes_review.py","limit":150},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":1329,"cache_read_input_tokens":42963,"output_tokens":100,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1329},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":100,"cache_read_input_tokens":42963,"cache_creation_input_tokens":1329,"cache_creation":{"ephemeral_5m_input_tokens":1329,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESGg82obLbAVKSFigk9","attributionAgent":"Explore","type":"assistant","uuid":"17421f3d-a8d1-47e6-b2a1-28251d90bd0e","timestamp":"2026-07-21T02:45:39.153Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"17421f3d-a8d1-47e6-b2a1-28251d90bd0e","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013YuhWRBUU6cFHcLrAUgQY8","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\timport os\n5\timport re\n6\timport subprocess\n7\tfrom dataclasses import asdict, dataclass\n8\tfrom datetime import datetime, timezone\n9\tfrom pathlib import Path\n10\tfrom typing import Any\n11\t\n12\tfrom PIL import Image, ImageStat\n13\t\n14\tfrom core.media.ffmpeg_utils import probe_media, probe_peak_volume_db\n15\t\n16\tREVIEW_VERSION = \"2026-07-11.v1\"\n17\t\n18\t\n19\t@dataclass(slots=True)\n20\tclass FrameMetrics:\n21\t    frame: str\n22\t    top_mean: float\n23\t    mid_mean: float\n24\t    bottom_mean: float\n25\t    mid_stddev: float\n26\t    edge_dark_ratio: float\n27\t    outro_candidate: bool\n28\t\n29\t\n30\t@dataclass(slots=True)\n31\tclass CliCheckResult:\n32\t    command: str\n33\t    ok: bool\n34\t    skipped: bool\n35\t    exit_code: int\n36\t    stdout: str\n37\t    stderr: str\n38\t\n39\t\n40\t@dataclass(slots=True)\n41\tclass HyperframesReviewReport:\n42\t    phase: str\n43\t    version: str\n44\t    generated_at: str\n45\t    project: str\n46\t    reference_project: str | None\n47\t    overall_score: int\n48\t    recommendation: str\n49\t    category_scores: dict[str, int]\n50\t    hard_failures: list[dict[str, Any]]\n51\t    top_issues: list[dict[str, Any]]\n52\t    deterministic_checks: dict[str, Any]\n53\t    comparison: dict[str, Any]\n54\t    cli_checks: list[dict[str, Any]]\n55\t    frame_metrics: list[dict[str, Any]]\n56\t    summary: str\n57\t\n58\t    def to_dict(self) -> dict[str, Any]:\n59\t        return asdict(self)\n60\t\n61\t\n62\tdef review_hyperframes_project(\n63\t    project_dir: Path,\n64\t    *,\n65\t    reference_dir: Path | None = None,\n66\t    run_cli_checks: bool = True,\n67\t    extract_frames: bool = True,\n68\t    frame_count: int = 6,\n69\t) -> HyperframesReviewReport:\n70\t    project_dir = project_dir.resolve()\n71\t    index_html = project_dir / \"index.html\"\n72\t    final_video = project_dir / \"final_listing_reel.mp4\"\n73\t    review_dir = project_dir / \"qa\" / \"hyperframes_review\"\n74\t    frames_dir = review_dir / \"frames\"\n75\t    review_dir.mkdir(parents=True, exist_ok=True)\n76\t\n77\t    html = index_html.read_text(encoding=\"utf-8\")\n78\t    scene_count = len(re.findall(r'class=\"scene clip\"', html))\n79\t    photo_count = len(re.findall(r'src=\"assets/photos/', html))\n80\t    declared_duration = _extract_float_attr(html, \"data-duration\")\n81\t    declared_width = _extract_int_attr(html, \"data-width\")\n82\t    declared_height = _extract_int_attr(html, \"data-height\")\n83\t    declared_portrait = declared_height > declared_width\n84\t    has_contact_outro = \"contact-main\" in html or \"LET'S\" in html\n85\t    has_profile_photo = bool(re.search(r\"(profile|headshot|avatar)\", html, re.IGNORECASE))\n86\t    photo_cover_rule = all(\n87\t        token in html\n88\t        for token in (\"object-fit: cover\", \".photo-frame { position: absolute; inset: 0;\", 'data-layout-allow-overflow')\n89\t    )\n90\t\n91\t    cli_checks = run_cli_review_checks(project_dir) if run_cli_checks else []\n92\t    probe = probe_media(final_video)\n93\t    audio_peak_db = probe_peak_volume_db(project_dir / \"narration.wav\") if (project_dir / \"narration.wav\").exists() else (\n94\t        probe_peak_volume_db(final_video) if probe.has_audio else float(\"-inf\")\n95\t    )\n96\t\n97\t    if extract_frames:\n98\t        frame_paths = extract_review_frames(final_video, frames_dir, frame_count=frame_count)\n99\t    else:\n100\t        frame_paths = sorted((project_dir / \"snapshots\").glob(\"frame-*.png\"))\n101\t    frames = [analyze_frame(path) for path in frame_paths]\n102\t    non_outro_frames = [frame for frame in frames if not frame.outro_candidate] or frames\n103\t    avg_mid_mean = _avg(frame.mid_mean for frame in non_outro_frames)\n104\t    avg_mid_stddev = _avg(frame.mid_stddev for frame in non_outro_frames)\n105\t    first_frame = non_outro_frames[0] if non_outro_frames else None\n106\t\n107\t    reference_comparison: dict[str, Any] = {}\n108\t    if reference_dir is not None:\n109\t        reference_video = reference_dir / \"final_listing_reel.mp4\"\n110\t        reference_frames_dir = review_dir / \"reference_frames\"\n111\t        reference_frame_paths = extract_review_frames(reference_video, reference_frames_dir, frame_count=frame_count)\n112\t        reference_frames = [analyze_frame(path) for path in reference_frame_paths]\n113\t        reference_non_outro = [frame for frame in reference_frames if not frame.outro_candidate] or reference_frames\n114\t        reference_comparison = {\n115\t            \"reference_project\": str(reference_dir),\n116\t            \"avg_mid_mean_delta\": round(avg_mid_mean - _avg(frame.mid_mean for frame in reference_non_outro), 1),\n117\t            \"avg_mid_stddev_delta\": round(avg_mid_stddev - _avg(frame.mid_stddev for frame in reference_non_outro), 1),\n118\t            \"first_frame_mid_stddev_delta\": round(\n119\t                (first_frame.mid_stddev if first_frame else 0.0) - (reference_non_outro[0].mid_stddev if reference_non_outro else 0.0),\n120\t                1,\n121\t            ),\n122\t        }\n123\t\n124\t    category_scores = {\n125\t        \"composition_script\": _score_script(cli_checks, scene_count, photo_count, declared_portrait, photo_cover_rule),\n126\t        \"visual_density\": _score_visual_density(avg_mid_mean, avg_mid_stddev),\n127\t        \"opening_framing\": _score_opening(first_frame, reference_comparison),\n128\t        \"motion_delivery\": _score_delivery(probe, declared_duration, scene_count),\n129\t        \"narration_audio\": _score_audio(probe.has_audio, audio_peak_db),\n130\t        \"outro_branding\": _score_outro(has_contact_outro, has_profile_photo),\n131\t    }\n132\t    overall = round(sum(category_scores.values()) / len(category_scores))\n133\t    issues = build_issues(\n134\t        category_scores=category_scores,\n135\t        cli_checks=cli_checks,\n136\t        has_profile_photo=has_profile_photo,\n137\t        has_contact_outro=has_contact_outro,\n138\t        has_audio=probe.has_audio,\n139\t        audio_peak_db=audio_peak_db,\n140\t        reference_comparison=reference_comparison,\n141\t        avg_mid_mean=avg_mid_mean,\n142\t        avg_mid_stddev=avg_mid_stddev,\n143\t        first_frame=first_frame,\n144\t    )\n145\t    hard_failures = [item for item in issues if item[\"severity\"] == \"error\"]\n146\t    recommendation = (\n147\t        \"reject\"\n148\t        if hard_failures\n149\t        else \"publishable\" if overall >= 85 and not issues else \"revise\" if overall >= 65 else \"reject\"\n150\t    )"}]},"uuid":"caba5d0a-0147-4ed5-a7cf-6e5f47373044","timestamp":"2026-07-21T02:45:39.183Z","sourceToolAssistantUUID":"17421f3d-a8d1-47e6-b2a1-28251d90bd0e","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"caba5d0a-0147-4ed5-a7cf-6e5f47373044","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGtuyLgSeENJgQJQop","type":"message","role":"assistant","content":[{"type":"text","text":"Let me look at how the score recommendation translates to failure and continue reading the rest of the review:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2350,"cache_read_input_tokens":44292,"cache_creation":{"ephemeral_5m_input_tokens":2350,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESGspjqh4vap17kP5UD","attributionAgent":"Explore","type":"assistant","uuid":"edeb07ce-d6f2-4a02-9984-525bbfda695c","timestamp":"2026-07-21T02:45:40.795Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"edeb07ce-d6f2-4a02-9984-525bbfda695c","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESGtuyLgSeENJgQJQop","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y8pd43NUGknZGmRjp322tz","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/hyperframes_review.py","offset":150,"limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2350,"cache_read_input_tokens":44292,"cache_creation":{"ephemeral_5m_input_tokens":2350,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESGspjqh4vap17kP5UD","attributionAgent":"Explore","type":"assistant","uuid":"47584fa8-5eb2-4bba-98cd-a1813d6862ba","timestamp":"2026-07-21T02:45:41.587Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"47584fa8-5eb2-4bba-98cd-a1813d6862ba","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y8pd43NUGknZGmRjp322tz","type":"tool_result","content":"150\t    )\n151\t    summary = build_summary(project_dir.name, overall, issues, reference_comparison)\n152\t    return HyperframesReviewReport(\n153\t        phase=\"video_review\",\n154\t        version=REVIEW_VERSION,\n155\t        generated_at=datetime.now(timezone.utc).isoformat(),\n156\t        project=str(project_dir),\n157\t        reference_project=str(reference_dir) if reference_dir else None,\n158\t        overall_score=overall,\n159\t        recommendation=recommendation,\n160\t        category_scores=category_scores,\n161\t        hard_failures=hard_failures,\n162\t        top_issues=issues[:5],\n163\t        deterministic_checks={\n164\t            \"scene_count\": scene_count,\n165\t            \"photo_count\": photo_count,\n166\t            \"declared_duration_seconds\": declared_duration,\n167\t            \"declared_width\": declared_width,\n168\t            \"declared_height\": declared_height,\n169\t            \"video_duration_seconds\": round(probe.duration_seconds, 3),\n170\t            \"video_width\": probe.width,\n171\t            \"video_height\": probe.height,\n172\t            \"video_frame_rate\": probe.frame_rate,\n173\t            \"video_has_audio\": probe.has_audio,\n174\t            \"audio_peak_db\": audio_peak_db,\n175\t            \"declared_portrait\": declared_portrait,\n176\t            \"has_contact_outro\": has_contact_outro,\n177\t            \"has_profile_photo\": has_profile_photo,\n178\t            \"avg_mid_mean\": round(avg_mid_mean, 1),\n179\t            \"avg_mid_stddev\": round(avg_mid_stddev, 1),\n180\t        },\n181\t        comparison=reference_comparison,\n182\t        cli_checks=[asdict(item) for item in cli_checks],\n183\t        frame_metrics=[asdict(frame) for frame in frames],\n184\t        summary=summary,\n185\t    )\n186\t\n187\t\n188\tCLI_CHECK_TIMEOUT_SECONDS = 90\n189\tSNAPSHOT_CHECK_TIMEOUT_SECONDS = 60\n190\t\n191\t\n192\tdef _review_check_env(project_dir: Path) -> dict[str, str]:\n193\t    env = dict(os.environ)\n194\t    env[\"NPM_CONFIG_CACHE\"] = str((project_dir / \".npm-cache\").resolve())\n195\t    # These checks only feed deterministic scoring, not the network-dependent\n196\t    # update nag or Gemini vision descriptions, both of which have caused the\n197\t    # subprocess to sit for minutes with no output.\n198\t    env[\"HYPERFRAMES_NO_UPDATE_CHECK\"] = \"1\"\n199\t    return env\n200\t\n201\t\n202\tdef _run_cli_check(command: list[str], *, project_dir: Path, env: dict[str, str], timeout: int) -> CliCheckResult:\n203\t    try:\n204\t        completed = subprocess.run(\n205\t            command, cwd=project_dir, capture_output=True, text=True, env=env, timeout=timeout\n206\t        )\n207\t    except subprocess.TimeoutExpired:\n208\t        return CliCheckResult(\n209\t            command=\" \".join(command),\n210\t            ok=False,\n211\t            skipped=False,\n212\t            exit_code=-1,\n213\t            stdout=\"\",\n214\t            stderr=f\"timed out after {timeout}s\",\n215\t        )\n216\t    return CliCheckResult(\n217\t        command=\" \".join(command),\n218\t        ok=completed.returncode == 0 or _is_browser_runtime_failure(completed.stdout, completed.stderr),\n219\t        skipped=_is_browser_runtime_failure(completed.stdout, completed.stderr),\n220\t        exit_code=completed.returncode,\n221\t        stdout=completed.stdout.strip(),\n222\t        stderr=completed.stderr.strip(),\n223\t    )\n224\t\n225\t\n226\tdef run_cli_review_checks(project_dir: Path) -> list[CliCheckResult]:\n227\t    runner = detect_hyperframes_runner(project_dir)\n228\t    env = _review_check_env(project_dir)\n229\t    checks: list[list[str]] = [\n230\t        [*runner, \"lint\", \"--json\"],\n231\t        [*runner, \"validate\", \"--json\"],\n232\t        [*runner, \"inspect\", \"--json\"],\n233\t    ]\n234\t    results = [\n235\t        _run_cli_check(command, project_dir=project_dir, env=env, timeout=CLI_CHECK_TIMEOUT_SECONDS)\n236\t        for command in checks\n237\t    ]\n238\t    # --describe false skips Gemini vision frame descriptions: we score frames\n239\t    # ourselves via analyze_frame(), and the vision call is otherwise unbounded\n240\t    # network I/O that has made this step look hung.\n241\t    snapshot_command = [*runner, \"snapshot\", \"--frames\", \"6\", \"--describe\", \"false\"]\n242\t    results.append(\n243\t        _run_cli_check(snapshot_command, project_dir=project_dir, env=env, timeout=SNAPSHOT_CHECK_TIMEOUT_SECONDS)\n244\t    )\n245\t    return results\n246\t\n247\t\n248\tdef run_cli_script_checks(project_dir: Path) -> list[CliCheckResult]:\n249\t    runner = detect_hyperframes_runner(project_dir)"}]},"uuid":"96e6f631-7799-471c-b4d9-261f9a02ec21","timestamp":"2026-07-21T02:45:41.605Z","sourceToolAssistantUUID":"47584fa8-5eb2-4bba-98cd-a1813d6862ba","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"96e6f631-7799-471c-b4d9-261f9a02ec21","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESH6G6Gb3kFHEkcEkE8","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check if there's any existing retry logic:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":1512,"cache_read_input_tokens":46642,"cache_creation":{"ephemeral_5m_input_tokens":1512,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESH4c8VnJnVPB7XkFX1","attributionAgent":"Explore","type":"assistant","uuid":"d29cd245-b323-4756-9d36-16bd587bd5c8","timestamp":"2026-07-21T02:45:43.413Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"d29cd245-b323-4756-9d36-16bd587bd5c8","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESH6G6Gb3kFHEkcEkE8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019viq1xtVARLUidzaojW3Tv","name":"Bash","input":{"command":"grep -r \"retry\\|loop\\|attempt\" /workspace/extra/projects/stagehand/core/workflows --include=\"*.py\" | head -20"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":1512,"cache_read_input_tokens":46642,"output_tokens":100,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1512},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":100,"cache_read_input_tokens":46642,"cache_creation_input_tokens":1512,"cache_creation":{"ephemeral_5m_input_tokens":1512,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESH4c8VnJnVPB7XkFX1","attributionAgent":"Explore","type":"assistant","uuid":"0ad68efb-ff18-4e67-99a0-f1a5555cf031","timestamp":"2026-07-21T02:45:43.899Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"0ad68efb-ff18-4e67-99a0-f1a5555cf031","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019viq1xtVARLUidzaojW3Tv","type":"tool_result","content":"/workspace/extra/projects/stagehand/core/workflows/modules.py:    # Strip Whisper hallucination tail: any repeating sequence of 2+ chars looping 10+ times\n/workspace/extra/projects/stagehand/core/workflows/modules.py:    # words with any of the three preceding sentences, Whisper is looping — trim it.","is_error":false}]},"uuid":"dcbf1b77-d443-4654-8a01-e591aecb3623","timestamp":"2026-07-21T02:45:43.933Z","sourceToolAssistantUUID":"0ad68efb-ff18-4e67-99a0-f1a5555cf031","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"dcbf1b77-d443-4654-8a01-e591aecb3623","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHEfcgHhbxNAJtgFyB","type":"message","role":"assistant","content":[{"type":"text","text":"Let me check the rest of the modules.py to see how rendering is integrated:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":196,"cache_read_input_tokens":48154,"cache_creation":{"ephemeral_5m_input_tokens":196,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESHDrFfQ2VbjpNwXTYk","attributionAgent":"Explore","type":"assistant","uuid":"da38c4ea-85a2-444b-92ba-a506bf56a446","timestamp":"2026-07-21T02:45:45.560Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"da38c4ea-85a2-444b-92ba-a506bf56a446","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHEfcgHhbxNAJtgFyB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TFUjX1aSxBGibrQjr6bn6F","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/modules.py","offset":290,"limit":150},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":196,"cache_read_input_tokens":48154,"output_tokens":122,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":196},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":122,"cache_read_input_tokens":48154,"cache_creation_input_tokens":196,"cache_creation":{"ephemeral_5m_input_tokens":196,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHDrFfQ2VbjpNwXTYk","attributionAgent":"Explore","type":"assistant","uuid":"6015d66f-4249-4a2a-a35a-6ab58254fb97","timestamp":"2026-07-21T02:45:46.104Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"6015d66f-4249-4a2a-a35a-6ab58254fb97","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TFUjX1aSxBGibrQjr6bn6F","type":"tool_result","content":"290\t\n291\t\n292\tclass InstagramPackageModule:\n293\t    \"\"\"Assemble the manual-handoff package: video copy, caption, metadata, thumbnail.\"\"\"\n294\t\n295\t    def run(self, context: WorkflowContext, params: dict) -> dict:\n296\t        aspect_ratio = params.get(\"aspect_ratio\", \"9:16\")\n297\t        publish_dir = context.workspace / \"publish\"\n298\t        publish_dir.mkdir(parents=True, exist_ok=True)\n299\t\n300\t        caption = (\n301\t            context.data.get(\"approved_caption\")\n302\t            or context.data.get(\"approved_script\")\n303\t            or context.data.get(\"caption_draft\")\n304\t            or context.data.get(\"generated_script\")\n305\t            or \"New video.\"\n306\t        )\n307\t        hashtags = context.data.get(\"instagram_hashtags\")\n308\t        handle = str(context.data.get(\"instagram_handle\") or \"\")\n309\t        tenant_id = getattr(context.job, \"tenant_id\", \"\")\n310\t\n311\t        # Source video being published: the rendered output if present, else the input.\n312\t        source_video = context.data.get(\"output_video\") or context.data.get(\"input_path\")\n313\t        publish_video_path = None\n314\t        thumbnail_path = None\n315\t        cover_path = None\n316\t        listing_snapshot_package_path = None\n317\t        duration_seconds = None\n318\t        if source_video and Path(source_video).exists():\n319\t            src = Path(source_video)\n320\t            delivery_title = resolve_delivery_title(\n321\t                explicit=str(context.data.get(\"delivery_title\") or \"\"),\n322\t                listing_snapshot_path=context.data.get(\"listing_snapshot_path\"),\n323\t                listing_url=str(context.data.get(\"listing_url\") or \"\"),\n324\t                listing_description=str(context.data.get(\"listing_description\") or \"\"),\n325\t                source_video=src,\n326\t            )\n327\t            delivery_content_type = resolve_delivery_content_type(\n328\t                explicit=str(context.data.get(\"delivery_content_type\") or \"\"),\n329\t                listing_snapshot_path=context.data.get(\"listing_snapshot_path\"),\n330\t                listing_url=str(context.data.get(\"listing_url\") or \"\"),\n331\t                title=delivery_title,\n332\t                description=str(context.data.get(\"listing_description\") or \"\"),\n333\t                source_video=src,\n334\t                default_content_type=\"video\",\n335\t            )\n336\t            publish_video = publish_dir / format_delivery_video_filename(\n337\t                source_video=src,\n338\t                delivery_title=delivery_title,\n339\t                delivery_content_type=delivery_content_type,\n340\t            )\n341\t            shutil.copyfile(src, publish_video)\n342\t            publish_video_path = publish_video\n343\t            duration_seconds = _probe_duration(src)\n344\t            designed_cover = context.data.get(\"cover_image_path\")\n345\t            if designed_cover and Path(designed_cover).exists():\n346\t                cover_source = Path(designed_cover)\n347\t                suffix = cover_source.suffix.lower()\n348\t                if suffix not in {\".jpg\", \".jpeg\", \".png\"}:\n349\t                    suffix = \".jpg\"\n350\t                cover_path = publish_dir / f\"cover{suffix}\"\n351\t                shutil.copyfile(cover_source, cover_path)\n352\t                thumbnail_path = cover_path\n353\t            else:\n354\t                thumbnail = publish_dir / \"thumbnail.png\"\n355\t                try:\n356\t                    at = min(1.0, (duration_seconds or 1.0) / 2)\n357\t                    create_thumbnail(src, thumbnail, at_seconds=at, aspect_ratio=aspect_ratio)\n358\t                    thumbnail_path = thumbnail\n359\t                except Exception:\n360\t                    thumbnail_path = None  # caption + video are enough for manual posting\n361\t\n362\t        snapshot_source = context.data.get(\"listing_snapshot_path\")\n363\t        if snapshot_source and Path(snapshot_source).exists():\n364\t            listing_snapshot_package_path = publish_dir / \"listing_snapshot.json\"\n365\t            shutil.copyfile(snapshot_source, listing_snapshot_package_path)\n366\t\n367\t        caption_path = publish_dir / \"caption.txt\"\n368\t        caption_path.write_text(caption, encoding=\"utf-8\")\n369\t\n370\t        metadata = build_metadata(\n371\t            tenant_id=tenant_id,\n372\t            handle=handle,\n373\t            aspect_ratio=aspect_ratio,\n374\t            duration_seconds=duration_seconds,\n375\t            caption=caption,\n376\t            hashtags=hashtags,\n377\t            video_filename=publish_video_path.name if publish_video_path else \"\",\n378\t            cover_filename=cover_path.name if cover_path else \"\",\n379\t            listing_snapshot_filename=(\n380\t                listing_snapshot_package_path.name if listing_snapshot_package_path else \"\"\n381\t            ),\n382\t            review_filename=Path(str(context.data.get(\"review_page_path\") or \"\")).name,\n383\t            review_url=str(context.data.get(\"review_page_url\") or \"\"),\n384\t        )\n385\t        metadata_path = publish_dir / \"metadata.json\"\n386\t        metadata_path.write_text(json.dumps(metadata, indent=2), encoding=\"utf-8\")\n387\t\n388\t        return {\n389\t            \"publish_dir\": str(publish_dir),\n390\t            \"caption_path\": str(caption_path),\n391\t            \"metadata_path\": str(metadata_path),\n392\t            \"publish_video_path\": str(publish_video_path) if publish_video_path else None,\n393\t            \"thumbnail_path\": str(thumbnail_path) if thumbnail_path else None,\n394\t            \"cover_path\": str(cover_path) if cover_path else None,\n395\t            \"listing_snapshot_package_path\": (\n396\t                str(listing_snapshot_package_path) if listing_snapshot_package_path else None\n397\t            ),\n398\t            \"review_page_path\": str(context.data.get(\"review_page_path\") or \"\") or None,\n399\t            \"review_page_url\": str(context.data.get(\"review_page_url\") or \"\") or None,\n400\t            \"publish_method\": \"manual_handoff\",\n401\t        }\n402\t\n403\t\n404\tdef default_modules(tts_client: Any = None) -> dict:\n405\t    from core.workflows.runner import ApprovalGate\n406\t    from core.workflows.listing_reel_modules import (\n407\t        ListingGraphicsModule,\n408\t        ListingIntakeModule,\n409\t        ListingPhotoReelRenderModule,\n410\t        ListingReviewArtifactsModule,\n411\t        ListingReviewApprovalModule,\n412\t        ListingReviewDraftModule,\n413\t        ListingScriptReviewModule,\n414\t        ListingVideoReviewModule,\n415\t    )\n416\t\n417\t    return {\n418\t        \"transcription\": LocalTranscriptionModule(),\n419\t        \"llm_script\": LocalScriptModule(),\n420\t        \"approval_gate\": ApprovalGate(),\n421\t        \"voice_synthesis\": LocalVoiceModule(tts_client),\n422\t        \"captions\": LocalCaptionModule(),\n423\t        \"video_format\": LocalRenderModule(),\n424\t        \"instagram_caption\": InstagramCaptionModule(),\n425\t        \"instagram_package\": InstagramPackageModule(),\n426\t        \"listing_intake\": ListingIntakeModule(),\n427\t        \"listing_review_draft\": ListingReviewDraftModule(),\n428\t        \"listing_review_approval\": ListingReviewApprovalModule(),\n429\t        \"listing_script_review\": ListingScriptReviewModule(),\n430\t        \"listing_pre_render_score\": ListingScriptReviewModule(),\n431\t        \"listing_graphics\": ListingGraphicsModule(),\n432\t        \"listing_photo_reel\": ListingPhotoReelRenderModule(),\n433\t        \"listing_video_review\": ListingVideoReviewModule(),\n434\t        \"listing_post_render_score\": ListingVideoReviewModule(),\n435\t        \"listing_review_artifacts\": ListingReviewArtifactsModule(),\n436\t    }\n437\t\n438\t\n439\t# ── Audio extraction ──────────────────────────────────────────────────────────"}]},"uuid":"31296159-fa02-4d97-8b99-9e507406cf16","timestamp":"2026-07-21T02:45:46.119Z","sourceToolAssistantUUID":"6015d66f-4249-4a2a-a35a-6ab58254fb97","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"31296159-fa02-4d97-8b99-9e507406cf16","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHQxVFS23cwDcEyB52","type":"message","role":"assistant","content":[{"type":"text","text":"Let me look for the store and job state management:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2310,"cache_read_input_tokens":48350,"cache_creation":{"ephemeral_5m_input_tokens":2310,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESHPM1DhnREXPRTtUYX","attributionAgent":"Explore","type":"assistant","uuid":"591c73cc-ff97-44f5-b28d-1355b8e6ab81","timestamp":"2026-07-21T02:45:47.890Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"591c73cc-ff97-44f5-b28d-1355b8e6ab81","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHQxVFS23cwDcEyB52","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019MnpJxrec359ShQLiGmtCK","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/core/workflows/store.py"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":2310,"cache_read_input_tokens":48350,"output_tokens":82,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":2310},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":82,"cache_read_input_tokens":48350,"cache_creation_input_tokens":2310,"cache_creation":{"ephemeral_5m_input_tokens":2310,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHPM1DhnREXPRTtUYX","attributionAgent":"Explore","type":"assistant","uuid":"afbe4e2a-4474-4661-9b79-08bb059d4d5f","timestamp":"2026-07-21T02:45:48.202Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"afbe4e2a-4474-4661-9b79-08bb059d4d5f","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019MnpJxrec359ShQLiGmtCK","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport json\n4\tfrom dataclasses import asdict\n5\tfrom pathlib import Path\n6\t\n7\tfrom core.models import ApprovalStatus, Job, JobStatus, JobStep, StepStatus\n8\t\n9\t\n10\tclass JsonJobStore:\n11\t    def __init__(self, root: str | Path) -> None:\n12\t        self.root = Path(root)\n13\t        self.root.mkdir(parents=True, exist_ok=True)\n14\t\n15\t    def save(self, job: Job) -> None:\n16\t        path = self._path(job.id)\n17\t        path.write_text(json.dumps(_job_to_dict(job), indent=2), encoding=\"utf-8\")\n18\t\n19\t    def get(self, job_id: str) -> Job:\n20\t        path = self._path(job_id)\n21\t        if not path.exists():\n22\t            raise KeyError(f\"Unknown job id: {job_id}\")\n23\t        return _job_from_dict(json.loads(path.read_text(encoding=\"utf-8\")))\n24\t\n25\t    def _path(self, job_id: str) -> Path:\n26\t        return self.root / f\"{job_id}.json\"\n27\t\n28\t\n29\tdef _job_to_dict(job: Job) -> dict:\n30\t    data = asdict(job)\n31\t    data[\"status\"] = job.status.value\n32\t    data[\"approval_status\"] = job.approval_status.value\n33\t    for index, step in enumerate(job.steps):\n34\t        data[\"steps\"][index][\"status\"] = step.status.value\n35\t    return data\n36\t\n37\t\n38\tdef _job_from_dict(data: dict) -> Job:\n39\t    steps = [\n40\t        JobStep(\n41\t            step_id=step[\"step_id\"],\n42\t            status=StepStatus(step[\"status\"]),\n43\t            input_data=step.get(\"input_data\", {}),\n44\t            output_data=step.get(\"output_data\", {}),\n45\t            approved_by=step.get(\"approved_by\"),\n46\t            approved_at=step.get(\"approved_at\"),\n47\t            duration_ms=step.get(\"duration_ms\"),\n48\t        )\n49\t        for step in data.get(\"steps\", [])\n50\t    ]\n51\t    return Job(\n52\t        id=data[\"id\"],\n53\t        tenant_id=data[\"tenant_id\"],\n54\t        workflow_name=data[\"workflow_name\"],\n55\t        input_params=data[\"input_params\"],\n56\t        status=JobStatus(data[\"status\"]),\n57\t        output_params=data.get(\"output_params\", {}),\n58\t        approval_status=ApprovalStatus(data.get(\"approval_status\", \"not_required\")),\n59\t        approved_at=data.get(\"approved_at\"),\n60\t        error_message=data.get(\"error_message\"),\n61\t        started_at=data.get(\"started_at\"),\n62\t        completed_at=data.get(\"completed_at\"),\n63\t        steps=steps,\n64\t    )\n65\t"}]},"uuid":"590f7c51-6a85-497d-875d-8b85a41b1ac1","timestamp":"2026-07-21T02:45:48.213Z","sourceToolAssistantUUID":"afbe4e2a-4474-4661-9b79-08bb059d4d5f","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"590f7c51-6a85-497d-875d-8b85a41b1ac1","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHZPkD7Yr7zFMEXYpd","type":"message","role":"assistant","content":[{"type":"text","text":"Excellent! Now let me check the CLAUDE.md file to understand the project documentation:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":897,"cache_read_input_tokens":50660,"cache_creation":{"ephemeral_5m_input_tokens":897,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESHYApf3nbWmm14KbJ5","attributionAgent":"Explore","type":"assistant","uuid":"dada4e9e-fd69-4336-884b-9864a7ead8b3","timestamp":"2026-07-21T02:45:49.745Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"dada4e9e-fd69-4336-884b-9864a7ead8b3","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHZPkD7Yr7zFMEXYpd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Pzfc9n8oW21xo88325vZe8","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/CLAUDE.md"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":897,"cache_read_input_tokens":50660,"output_tokens":87,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":897},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":87,"cache_read_input_tokens":50660,"cache_creation_input_tokens":897,"cache_creation":{"ephemeral_5m_input_tokens":897,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHYApf3nbWmm14KbJ5","attributionAgent":"Explore","type":"assistant","uuid":"95674855-7415-45e2-aed4-709442d7bb22","timestamp":"2026-07-21T02:45:49.972Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"95674855-7415-45e2-aed4-709442d7bb22","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Pzfc9n8oW21xo88325vZe8","type":"tool_result","content":"1\t# Stagehand Project Instructions\n2\t> Symlinked as `AGENTS.md`; edits affect both. Every agent (Claude Code, Codex) must read the same project instructions from here — do not reintroduce a divergent `AGENTS.md`.\n3\t\n4\tThis project is building a concierge-first local AI workflow service that can later become a platform.\n5\t\n6\t## Listing Reel Engine (canonical)\n7\t\n8\tThe listing reel is rendered with **HyperFrames** (HTML → video). This is the one canonical engine for every agent — outputs live under `output/hyperframes/<project>/` as a HyperFrames project (`index.html`, `hyperframes.json`, `final_listing_reel.mp4` + `_delivery`). QA it with the repo-local skill `skills/hyperframes-reel-review/SKILL.md`.\n9\t\n10\t- The older ffmpeg photo-slideshow path (`python -m cli.main run …`, writing `output/<uuid>/final_video.mp4` via `core/media/photo_reel.py`) is **legacy**. It stays for its tests and existing jobs, but it is not the default: do not build new listing reels through it. If a reel is requested, use HyperFrames.\n11\t- Rationale: the two paths previously produced qualitatively different videos (animated composition vs. Ken-Burns slideshow) in different folders depending on which agent ran — see the convergence decision. One engine, one output location.\n12\t\n13\t## Execution Standard\n14\t\n15\t- Use `docs/punch-list.md` as the project todo list and keep it current.\n16\t- Before starting a meaningful task, identify which punch-list items it advances.\n17\t- When work is completed and verified, update the checkbox in `docs/punch-list.md`.\n18\t- Do not mark discovery items complete until the required user/client input exists.\n19\t- Keep raw client/demo media, generated outputs, secrets, and private addresses out of git.\n20\t- For repeatable local HyperFrames reel QA, use the repo-local skill at `skills/hyperframes-reel-review/SKILL.md`.\n21\t\n22\t## Testing Standard\n23\t\n24\t- Testing is non-negotiable.\n25\t- Use TDD for deterministic business logic, config parsing, tenant scoping, approval gates, CLI behavior, and media command construction.\n26\t- Use BDD-style tests for multi-step workflow behavior such as \"generated script pauses for approval\" and \"approved script resumes rendering.\"\n27\t- The default test suite must not require live paid API calls or network access.\n28\t- Live provider tests must be opt-in and guarded by explicit environment flags.\n29\t- For workflow or media-rendering changes, run a local end-to-end render through approval with a real fixture before calling the work implemented. Inspect or verify the produced video artifact enough to catch issues that unit tests miss, such as silent TTS fallback, static captions, source-audio leakage, or render duration problems.\n30\t- The full test suite runs automatically at the end of every turn via a `Stop` hook (`pytest -q`, configured in `.claude/settings.json`). Treat a failure it surfaces as a regression to fix before handing back; do not rely on it as a gate (it is non-blocking), and still run targeted tests while iterating.\n31\t\n32\t## Current Scope\n33\t\n34\t- Build a local CLI workflow first.\n35\t- Keep the system concierge-first: human review is part of the product.\n36\t- Do not add social posting, billing, authentication, or a web UI until the first workflow is proven.\n37\t- Vercel is acceptable later for thin UI/API surfaces, Neon for hosted Postgres, and Cloudflare for DNS/domains.\n38\t- Long-running video rendering should remain in a local/background worker, not in a Vercel request handler.\n39\t"}]},"uuid":"afbdee12-e751-43a3-90aa-3292c5802557","timestamp":"2026-07-21T02:45:49.980Z","sourceToolAssistantUUID":"95674855-7415-45e2-aed4-709442d7bb22","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"afbdee12-e751-43a3-90aa-3292c5802557","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHi8c6y7Fq6EZiqVtR","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me look at one specific workflow config to fully understand the complete picture:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":1141,"cache_read_input_tokens":51557,"cache_creation":{"ephemeral_5m_input_tokens":1141,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESHg1rtrBoMvzH6KaxU","attributionAgent":"Explore","type":"assistant","uuid":"0b9c818b-08a1-4c48-920e-7e59b7153fe6","timestamp":"2026-07-21T02:45:51.888Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"0b9c818b-08a1-4c48-920e-7e59b7153fe6","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHi8c6y7Fq6EZiqVtR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012AKn8s8rytnusSUB2Nte7o","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":1141,"cache_read_input_tokens":51557,"output_tokens":90,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1141},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":90,"cache_read_input_tokens":51557,"cache_creation_input_tokens":1141,"cache_creation":{"ephemeral_5m_input_tokens":1141,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHg1rtrBoMvzH6KaxU","attributionAgent":"Explore","type":"assistant","uuid":"84aca86c-4563-430a-b6db-2950d8f2dfd3","timestamp":"2026-07-21T02:45:52.293Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"84aca86c-4563-430a-b6db-2950d8f2dfd3","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012AKn8s8rytnusSUB2Nte7o","type":"tool_result","content":"Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead."}]},"uuid":"91efa3b1-bb10-4dbf-adbb-bae652841db1","timestamp":"2026-07-21T02:45:52.313Z","sourceToolAssistantUUID":"84aca86c-4563-430a-b6db-2950d8f2dfd3","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"91efa3b1-bb10-4dbf-adbb-bae652841db1","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHrkH2UB9w9ehPmz4g","type":"message","role":"assistant","content":[{"type":"text","text":"Let me check if there are any existing retry implementations in the tests:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":123,"cache_read_input_tokens":52698,"cache_creation":{"ephemeral_5m_input_tokens":123,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011CdESHqoE8fjc8fatu32XH","attributionAgent":"Explore","type":"assistant","uuid":"f1f879b1-a429-4b79-bb7e-a7aac899546f","timestamp":"2026-07-21T02:45:53.705Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"f1f879b1-a429-4b79-bb7e-a7aac899546f","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHrkH2UB9w9ehPmz4g","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01APua4sUyAgG2NBTUz13Pjb","name":"Bash","input":{"command":"find /workspace/extra/projects/stagehand/tests -name \"*.py\" | xargs grep -l \"retry\\|resume\\|loop\" | head -10"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":123,"cache_read_input_tokens":52698,"output_tokens":104,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":123},"inference_geo":"","iterations":[{"input_tokens":5,"output_tokens":104,"cache_read_input_tokens":52698,"cache_creation_input_tokens":123,"cache_creation":{"ephemeral_5m_input_tokens":123,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHqoE8fjc8fatu32XH","attributionAgent":"Explore","type":"assistant","uuid":"2bbc5161-28fd-4706-93ad-9412cd6d6f6a","timestamp":"2026-07-21T02:45:54.103Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"2bbc5161-28fd-4706-93ad-9412cd6d6f6a","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01APua4sUyAgG2NBTUz13Pjb","type":"tool_result","content":"/workspace/extra/projects/stagehand/tests/test_workflow_runner.py\n/workspace/extra/projects/stagehand/tests/test_listing_reel_workflow.py\n/workspace/extra/projects/stagehand/tests/test_cli.py","is_error":false}]},"uuid":"4576bd41-5cf2-4edd-9560-043e78bcd5f8","timestamp":"2026-07-21T02:45:54.126Z","sourceToolAssistantUUID":"2bbc5161-28fd-4706-93ad-9412cd6d6f6a","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"4576bd41-5cf2-4edd-9560-043e78bcd5f8","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESHzTNg9H3HM9v4Hcp5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XZbtut4yB2FF8Y7JJh7Uhp","name":"Read","input":{"file_path":"/workspace/extra/projects/stagehand/tests/test_workflow_runner.py"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":177,"cache_read_input_tokens":52821,"output_tokens":72,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":177},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":72,"cache_read_input_tokens":52821,"cache_creation_input_tokens":177,"cache_creation":{"ephemeral_5m_input_tokens":177,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESHycmqTT27UzfQB11f","attributionAgent":"Explore","type":"assistant","uuid":"4511d565-8676-4f1f-ad3b-330941314666","timestamp":"2026-07-21T02:45:55.941Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"4511d565-8676-4f1f-ad3b-330941314666","isSidechain":true,"promptId":"b70ff47c-595d-44e4-ad62-125626587561","agentId":"a64b6f12d31791766","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XZbtut4yB2FF8Y7JJh7Uhp","type":"tool_result","content":"1\tfrom __future__ import annotations\n2\t\n3\timport tempfile\n4\timport unittest\n5\tfrom pathlib import Path\n6\tfrom unittest.mock import MagicMock, patch\n7\t\n8\tfrom core.media.local_tts import LocalTtsError\n9\tfrom core.models import ApprovalStatus, JobStatus\n10\tfrom core.workflows.config import load_workflow_config\n11\tfrom core.workflows.modules import LocalTranscriptionModule, _apply_clip_speeds, _simple_srt, default_modules\n12\tfrom core.workflows.runner import WorkflowContext, WorkflowRunner\n13\tfrom core.workflows.store import JsonJobStore\n14\t\n15\t\n16\tclass WorkflowRunnerScenarioTests(unittest.TestCase):\n17\t    def test_failed_post_approval_step_is_persisted_and_can_resume(self) -> None:\n18\t        class DraftModule:\n19\t            def run(self, context, params):\n20\t                return {\"generated_script\": \"Review me.\"}\n21\t\n22\t        class FailOnceModule:\n23\t            def __init__(self) -> None:\n24\t                self.should_fail = True\n25\t\n26\t            def run(self, context, params):\n27\t                if self.should_fail:\n28\t                    raise RuntimeError(\"provider unavailable\")\n29\t                return {\"artifact\": \"complete\"}\n30\t\n31\t        from core.workflows.config import WorkflowConfig, WorkflowStep\n32\t        from core.workflows.runner import ApprovalGate\n33\t\n34\t        with tempfile.TemporaryDirectory() as tmp:\n35\t            root = Path(tmp)\n36\t            store = JsonJobStore(root / \"jobs\")\n37\t            flaky = FailOnceModule()\n38\t            runner = WorkflowRunner(\n39\t                WorkflowConfig(\n40\t                    name=\"recoverable\",\n41\t                    description=\"\",\n42\t                    steps=[\n43\t                        WorkflowStep(\"draft\", \"draft\", {}),\n44\t                        WorkflowStep(\"approve\", \"approval_gate\", {}),\n45\t                        WorkflowStep(\"provider\", \"provider\", {}),\n46\t                    ],\n47\t                ),\n48\t                {\"draft\": DraftModule(), \"approval_gate\": ApprovalGate(), \"provider\": flaky},\n49\t                store,\n50\t                output_root=root / \"output\",\n51\t            )\n52\t            waiting = runner.start(\"demo\", {})\n53\t            approval_path = Path(waiting.output_params[\"approval_artifact\"])\n54\t\n55\t            with self.assertRaisesRegex(RuntimeError, \"provider unavailable\"):\n56\t                runner.approve(waiting.id, approval_path)\n57\t\n58\t            failed = store.get(waiting.id)\n59\t            self.assertEqual(failed.status, JobStatus.FAILED)\n60\t            self.assertEqual(failed.error_message, \"provider unavailable\")\n61\t            self.assertEqual(failed.steps[-1].status.value, \"failed\")\n62\t\n63\t            flaky.should_fail = False\n64\t            completed = runner.resume(waiting.id)\n65\t\n66\t        self.assertEqual(completed.status, JobStatus.COMPLETED)\n67\t        self.assertIsNone(completed.error_message)\n68\t        self.assertEqual(completed.output_params[\"artifact\"], \"complete\")\n69\t\n70\t    def test_resume_rejects_jobs_that_are_not_approved_failures(self) -> None:\n71\t        with tempfile.TemporaryDirectory() as tmp:\n72\t            root = Path(tmp)\n73\t            runner = WorkflowRunner(\n74\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n75\t                default_modules(),\n76\t                JsonJobStore(root / \"jobs\"),\n77\t                output_root=root / \"output\",\n78\t            )\n79\t            waiting = runner.start(\"demo\", {\"input_path\": \"input/demo.mov\"})\n80\t\n81\t            with self.assertRaisesRegex(ValueError, \"approved failed job\"):\n82\t                runner.resume(waiting.id)\n83\t\n84\t    def test_video_workflow_pauses_for_approval_then_resumes(self) -> None:\n85\t        with tempfile.TemporaryDirectory() as tmp:\n86\t            root = Path(tmp)\n87\t            store = JsonJobStore(root / \"jobs\")\n88\t            runner = WorkflowRunner(\n89\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n90\t                default_modules(),\n91\t                store,\n92\t                output_root=root / \"output\",\n93\t            )\n94\t\n95\t            job = runner.start(\n96\t                tenant_id=\"demo\",\n97\t                input_params={\n98\t                    \"input_path\": \"input/IMG_2157.MOV\",\n99\t                    \"listing_description\": \"Demo walkthrough in Cedar Park.\",\n100\t                    \"transcript\": \"So this is the kitchen, um, then we walk into the living room and back patio.\",\n101\t                },\n102\t            )\n103\t\n104\t            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)\n105\t            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)\n106\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n107\t            self.assertTrue(approval_path.exists())\n108\t            approval_text = approval_path.read_text(encoding=\"utf-8\")\n109\t            self.assertIn(\"This is the kitchen\", approval_text)\n110\t            self.assertNotIn(\" um,\", approval_text)\n111\t            self.assertNotIn(\"output_video\", job.output_params)\n112\t\n113\t            approval_path.write_text(\"Approved narration for the demo video.\", encoding=\"utf-8\")\n114\t            completed = runner.approve(job.id, approval_path, approved_by=\"tester\")\n115\t\n116\t            self.assertEqual(completed.status, JobStatus.COMPLETED)\n117\t            self.assertEqual(completed.approval_status, ApprovalStatus.APPROVED)\n118\t            self.assertIn(\"output_video\", completed.output_params)\n119\t            self.assertIn(\n120\t                completed.output_params[\"voice_provider\"],\n121\t                {\"local_say\", \"local_stub_silent_audio\", \"local_say_failed_silent_audio\", \"local_say_timed\"},\n122\t            )\n123\t            self.assertTrue((root / \"output\" / job.id / \"render_command.txt\").exists())\n124\t            self.assertNotIn(\"caption_image_path\", completed.output_params)\n125\t            self.assertGreaterEqual(len(completed.output_params[\"caption_overlays\"]), 1)\n126\t\n127\t    def test_local_say_tts_is_used_when_available(self) -> None:\n128\t        with tempfile.TemporaryDirectory() as tmp, patch(\"core.workflows.modules.say_is_available\", return_value=True), patch(\n129\t            \"core.workflows.modules.create_say_audio\"\n130\t        ) as say_audio:\n131\t            root = Path(tmp)\n132\t            store = JsonJobStore(root / \"jobs\")\n133\t            runner = WorkflowRunner(\n134\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n135\t                default_modules(),\n136\t                store,\n137\t                output_root=root / \"output\",\n138\t            )\n139\t            job = runner.start(\"demo\", {\"input_path\": \"input/IMG_2157.MOV\", \"say_voice\": \"Samantha\", \"say_rate\": 170})\n140\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n141\t            approval_path.write_text(\"Approved narration for the demo video.\", encoding=\"utf-8\")\n142\t\n143\t            completed = runner.approve(job.id, approval_path)\n144\t\n145\t        self.assertEqual(completed.output_params[\"voice_provider\"], \"local_say\")\n146\t        say_audio.assert_called_once()\n147\t\n148\t    def test_local_say_falls_back_when_audio_generation_fails(self) -> None:\n149\t        with tempfile.TemporaryDirectory() as tmp, patch(\"core.workflows.modules.say_is_available\", return_value=True), patch(\n150\t            \"core.workflows.modules.create_say_audio\", side_effect=LocalTtsError(\"empty audio\")\n151\t        ), patch(\"core.workflows.modules.create_silent_audio\") as silent_audio:\n152\t            root = Path(tmp)\n153\t            store = JsonJobStore(root / \"jobs\")\n154\t            runner = WorkflowRunner(\n155\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n156\t                default_modules(),\n157\t                store,\n158\t                output_root=root / \"output\",\n159\t            )\n160\t            job = runner.start(\"demo\", {\"input_path\": \"input/IMG_2157.MOV\", \"say_voice\": \"Samantha\", \"say_rate\": 170})\n161\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n162\t            approval_path.write_text(\"Approved narration for the demo video.\", encoding=\"utf-8\")\n163\t\n164\t            completed = runner.approve(job.id, approval_path)\n165\t\n166\t        self.assertEqual(completed.output_params[\"voice_provider\"], \"local_say_failed_silent_audio\")\n167\t        silent_audio.assert_called_once()\n168\t\n169\t    def test_continuous_elevenlabs_uses_approved_narration(self) -> None:\n170\t        mock_client = MagicMock()\n171\t        mock_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b\"audio\")\n172\t        module = default_modules(tts_client=mock_client)[\"voice_synthesis\"]\n173\t        context = WorkflowContext(\n174\t            job=MagicMock(),\n175\t            workspace=Path(tempfile.mkdtemp()),\n176\t            data={\n177\t                \"approved_script\": \"The JSON approval artifact should not be narrated.\",\n178\t                \"approved_narration\": \"Approved property narration.\",\n179\t                \"local_tts_provider\": \"elevenlabs\",\n180\t                \"require_audible_narration\": True,\n181\t                \"target_duration_seconds\": 30,\n182\t            },\n183\t        )\n184\t        probe = MagicMock(duration_seconds=4.25)\n185\t\n186\t        with patch(\"core.workflows.modules.probe_media\", return_value=probe), patch(\n187\t            \"core.workflows.modules.probe_peak_volume_db\", return_value=-12.0\n188\t        ):\n189\t            result = module.run(context, {})\n190\t\n191\t        mock_client.synthesize_to_file.assert_called_once()\n192\t        self.assertEqual(mock_client.synthesize_to_file.call_args.args[0], \"Approved property narration.\")\n193\t        self.assertEqual(result[\"voice_provider\"], \"elevenlabs\")\n194\t        self.assertEqual(result[\"narration_duration_seconds\"], 4.25)\n195\t\n196\t    def test_strict_narration_rejects_missing_or_failed_provider_audio(self) -> None:\n197\t        module = default_modules()[\"voice_synthesis\"]\n198\t        context = WorkflowContext(\n199\t            job=MagicMock(),\n200\t            workspace=Path(tempfile.mkdtemp()),\n201\t            data={\n202\t                \"approved_narration\": \"Approved property narration.\",\n203\t                \"local_tts_provider\": \"elevenlabs\",\n204\t                \"require_audible_narration\": True,\n205\t            },\n206\t        )\n207\t        with self.assertRaisesRegex(RuntimeError, \"audible narration\"):\n208\t            module.run(context, {})\n209\t\n210\t        context.data[\"local_tts_provider\"] = \"silent\"\n211\t        with self.assertRaisesRegex(RuntimeError, \"audible narration\"):\n212\t            module.run(context, {})\n213\t\n214\t    def test_strict_narration_rejects_synthesized_silence(self) -> None:\n215\t        mock_client = MagicMock()\n216\t        mock_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b\"audio\")\n217\t        module = default_modules(tts_client=mock_client)[\"voice_synthesis\"]\n218\t        context = WorkflowContext(\n219\t            job=MagicMock(),\n220\t            workspace=Path(tempfile.mkdtemp()),\n221\t            data={\n222\t                \"approved_narration\": \"Approved property narration.\",\n223\t                \"local_tts_provider\": \"elevenlabs\",\n224\t                \"require_audible_narration\": True,\n225\t            },\n226\t        )\n227\t        with patch(\"core.workflows.modules.probe_media\", return_value=MagicMock(duration_seconds=4.0)), patch(\n228\t            \"core.workflows.modules.probe_peak_volume_db\", return_value=-91.0\n229\t        ):\n230\t            with self.assertRaisesRegex(RuntimeError, \"effectively silent\"):\n231\t                module.run(context, {})\n232\t\n233\t    def test_rejected_script_does_not_render(self) -> None:\n234\t        with tempfile.TemporaryDirectory() as tmp:\n235\t            root = Path(tmp)\n236\t            store = JsonJobStore(root / \"jobs\")\n237\t            runner = WorkflowRunner(\n238\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n239\t                default_modules(),\n240\t                store,\n241\t                output_root=root / \"output\",\n242\t            )\n243\t            job = runner.start(\"demo\", {\"input_path\": \"input/IMG_2157.MOV\"})\n244\t\n245\t            rejected = runner.reject(job.id, \"Needs factual correction\")\n246\t\n247\t            self.assertEqual(rejected.status, JobStatus.FAILED)\n248\t            self.assertEqual(rejected.approval_status, ApprovalStatus.REJECTED)\n249\t            self.assertNotIn(\"output_video\", rejected.output_params)\n250\t\n251\t    def test_manual_touch_time_is_tracked_per_job(self) -> None:\n252\t        with tempfile.TemporaryDirectory() as tmp:\n253\t            root = Path(tmp)\n254\t            store = JsonJobStore(root / \"jobs\")\n255\t            runner = WorkflowRunner(\n256\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n257\t                default_modules(),\n258\t                store,\n259\t                output_root=root / \"output\",\n260\t            )\n261\t            job = runner.start(\"demo\", {\"input_path\": \"input/IMG_2157.MOV\"})\n262\t\n263\t            updated = runner.log_touch_time(job.id, 4.5, \"Reviewed script\")\n264\t            updated = runner.log_touch_time(updated.id, 2.0, \"Prepared delivery\")\n265\t\n266\t            self.assertEqual(updated.output_params[\"manual_touch_minutes\"], 6.5)\n267\t            self.assertEqual(len(updated.output_params[\"manual_touch_log\"]), 2)\n268\t\n269\t            with self.assertRaises(ValueError):\n270\t                runner.log_touch_time(job.id, 0)\n271\t\n272\t    def test_local_demo_can_render_final_video_after_approval(self) -> None:\n273\t        with tempfile.TemporaryDirectory() as tmp:\n274\t            root = Path(tmp)\n275\t            store = JsonJobStore(root / \"jobs\")\n276\t            runner = WorkflowRunner(\n277\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n278\t                default_modules(),\n279\t                store,\n280\t                output_root=root / \"output\",\n281\t            )\n282\t            job = runner.start(\n283\t                \"demo\",\n284\t                {\n285\t                    \"input_path\": \"tests/fixtures/short_walkthrough.mp4\",\n286\t                    \"execute_render\": True,\n287\t                    \"local_tts_provider\": \"silent\",\n288\t                    \"transcript\": \"Short demo walkthrough.\",\n289\t                    \"target_duration_seconds\": 2,\n290\t                },\n291\t            )\n292\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n293\t            approval_path.write_text(\"Approved short fixture narration.\", encoding=\"utf-8\")\n294\t\n295\t            completed = runner.approve(job.id, approval_path)\n296\t\n297\t            output_video = Path(completed.output_params[\"output_video\"])\n298\t            self.assertTrue(output_video.exists())\n299\t            self.assertTrue(completed.output_params[\"render_executed\"])\n300\t            self.assertEqual(completed.output_params[\"target_duration_seconds\"], 2)\n301\t            self.assertEqual(completed.output_params[\"rendered_width\"], 1080)\n302\t            self.assertEqual(completed.output_params[\"rendered_height\"], 1920)\n303\t\n304\t    def test_captions_are_segmented_across_narration_duration(self) -> None:\n305\t        text = (\n306\t            \"Welcome inside this bright entry and open living room. \"\n307\t            \"The kitchen connects cleanly to the dining area. \"\n308\t            \"Down the hall, the bedrooms are quiet and comfortable.\"\n309\t        )\n310\t\n311\t        srt = _simple_srt(text, duration_seconds=12)\n312\t\n313\t        self.assertIn(\"1\\n00:00:00,000 -->\", srt)\n314\t        self.assertIn(\"\\n2\\n\", srt)\n315\t        self.assertIn(\"\\n3\\n\", srt)\n316\t        self.assertIn(\"00:00:12,000\", srt)\n317\t\n318\t    def test_transcript_cleanup_capitalizes_after_removed_filler(self) -> None:\n319\t        with tempfile.TemporaryDirectory() as tmp:\n320\t            root = Path(tmp)\n321\t            store = JsonJobStore(root / \"jobs\")\n322\t            runner = WorkflowRunner(\n323\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n324\t                default_modules(),\n325\t                store,\n326\t                output_root=root / \"output\",\n327\t            )\n328\t\n329\t            job = runner.start(\n330\t                \"demo\",\n331\t                {\n332\t                    \"input_path\": \"input/IMG_2157.MOV\",\n333\t                    \"transcript\": \"So welcome inside this bright entry. Um, the living room opens into the kitchen.\",\n334\t                },\n335\t            )\n336\t\n337\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n338\t            approval_text = approval_path.read_text(encoding=\"utf-8\")\n339\t\n340\t        self.assertIn(\"entry. The living room\", approval_text)\n341\t        self.assertNotIn(\"Um\", approval_text)\n342\t\n343\t    def test_whisper_client_transcribe_with_segments_is_called_and_stored(self) -> None:\n344\t        mock_client = MagicMock()\n345\t        mock_client.transcribe_with_segments.return_value = (\n346\t            \"This is the kitchen, then we walk into the living room.\",\n347\t            [\n348\t                {\"start\": 0.0, \"end\": 3.5, \"text\": \"This is the kitchen,\"},\n349\t                {\"start\": 3.5, \"end\": 7.0, \"text\": \"then we walk into the living room.\"},\n350\t            ],\n351\t        )\n352\t        module = LocalTranscriptionModule(mock_client)\n353\t        # Use a path that does not exist on disk so audio extraction is skipped;\n354\t        # this test exercises client wiring, not the extraction step.\n355\t        context = WorkflowContext(\n356\t            job=MagicMock(),\n357\t            workspace=Path(\"/tmp/stagehand_test_workspace\"),\n358\t            data={\"input_path\": \"input/nonexistent_walkthrough.mov\"},\n359\t        )\n360\t\n361\t        result = module.run(context, {})\n362\t\n363\t        mock_client.transcribe_with_segments.assert_called_once_with(\"input/nonexistent_walkthrough.mov\")\n364\t        self.assertEqual(result[\"transcript\"], \"This is the kitchen, then we walk into the living room.\")\n365\t        self.assertEqual(len(result[\"transcript_segments\"]), 2)\n366\t        self.assertAlmostEqual(result[\"transcript_segments\"][0][\"start\"], 0.0)\n367\t        self.assertAlmostEqual(result[\"transcript_segments\"][1][\"end\"], 7.0)\n368\t\n369\t    def test_zero_duration_segments_are_filtered_before_storage(self) -> None:\n370\t        mock_client = MagicMock()\n371\t        mock_client.transcribe_with_segments.return_value = (\n372\t            \"Kitchen then living room.\",\n373\t            [\n374\t                {\"start\": 0.0, \"end\": 3.5, \"text\": \"Kitchen\"},\n375\t                {\"start\": 3.5, \"end\": 3.5, \"text\": \"\"},   # zero-duration hallucination\n376\t                {\"start\": 3.5, \"end\": 3.5, \"text\": \"\"},   # another zero-duration\n377\t                {\"start\": 3.5, \"end\": 7.0, \"text\": \"then living room.\"},\n378\t            ],\n379\t        )\n380\t        module = LocalTranscriptionModule(mock_client)\n381\t        context = WorkflowContext(\n382\t            job=MagicMock(),\n383\t            workspace=Path(\"/tmp/stagehand_test_workspace\"),\n384\t            data={\"input_path\": \"input/nonexistent_walkthrough.mov\"},\n385\t        )\n386\t\n387\t        result = module.run(context, {})\n388\t\n389\t        self.assertEqual(len(result[\"transcript_segments\"]), 2)\n390\t        self.assertAlmostEqual(result[\"transcript_segments\"][0][\"end\"], 3.5)\n391\t        self.assertAlmostEqual(result[\"transcript_segments\"][1][\"start\"], 3.5)\n392\t        self.assertAlmostEqual(result[\"transcript_segments\"][1][\"end\"], 7.0)\n393\t\n394\t    def test_whisper_client_falls_back_to_transcribe_when_no_segments_method(self) -> None:\n395\t        mock_client = MagicMock(spec=[\"transcribe\"])\n396\t        mock_client.transcribe.return_value = \"A kitchen and living room.\"\n397\t        module = LocalTranscriptionModule(mock_client)\n398\t        context = WorkflowContext(\n399\t            job=MagicMock(),\n400\t            workspace=Path(\"/tmp/stagehand_test_workspace\"),\n401\t            data={\"input_path\": \"input/demo.mov\"},\n402\t        )\n403\t\n404\t        result = module.run(context, {})\n405\t\n406\t        mock_client.transcribe.assert_called_once_with(\"input/demo.mov\")\n407\t        self.assertEqual(result[\"transcript\"], \"A kitchen and living room.\")\n408\t        self.assertNotIn(\"transcript_segments\", result)\n409\t\n410\t    def test_transcription_module_offline_placeholder_is_explicit(self) -> None:\n411\t        module = LocalTranscriptionModule()\n412\t        context = WorkflowContext(\n413\t            job=MagicMock(),\n414\t            workspace=Path(\"/tmp\"),\n415\t            data={\"input_path\": \"input/no_client.mov\"},\n416\t        )\n417\t\n418\t        result = module.run(context, {})\n419\t\n420\t        self.assertIn(\"DEMO PLACEHOLDER\", result[\"transcript\"])\n421\t        self.assertIn(\"no_client.mov\", result[\"transcript\"])\n422\t\n423\t    def test_captions_use_whisper_segment_timestamps_scaled_to_tts_duration(self) -> None:\n424\t        with tempfile.TemporaryDirectory() as tmp:\n425\t            root = Path(tmp)\n426\t            store = JsonJobStore(root / \"jobs\")\n427\t            runner = WorkflowRunner(\n428\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n429\t                default_modules(),\n430\t                store,\n431\t                output_root=root / \"output\",\n432\t            )\n433\t            # Whisper reported 10s of original speech; TTS will run at 8s\n434\t            job = runner.start(\n435\t                \"demo\",\n436\t                {\n437\t                    \"input_path\": \"input/IMG_2157.MOV\",\n438\t                    \"transcript\": \"Welcome inside. The kitchen opens to the living room.\",\n439\t                    \"transcript_segments\": [\n440\t                        {\"start\": 0.0, \"end\": 2.0, \"text\": \"Welcome inside.\"},\n441\t                        {\"start\": 2.0, \"end\": 6.0, \"text\": \"The kitchen\"},\n442\t                        {\"start\": 6.0, \"end\": 10.0, \"text\": \"opens to the living room.\"},\n443\t                    ],\n444\t                    \"target_duration_seconds\": 8,\n445\t                    # Use silent TTS so narration_duration_seconds stays at\n446\t                    # target_duration_seconds (8s) and caption timestamps are deterministic.\n447\t                    \"local_tts_provider\": \"silent\",\n448\t                },\n449\t            )\n450\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n451\t            approval_path.write_text(\"Welcome inside. The kitchen opens to the living room.\", encoding=\"utf-8\")\n452\t            completed = runner.approve(job.id, approval_path)\n453\t\n454\t            overlays = completed.output_params[\"caption_overlays\"]\n455\t            # Captions should start at 0 and end at the narration duration (8s), not raw 10s\n456\t            self.assertAlmostEqual(overlays[0][\"start\"], 0.0)\n457\t            self.assertAlmostEqual(overlays[-1][\"end\"], 8.0, places=1)\n458\t            # Whisper-based timing reflects original pacing scaled to narration duration\n459\t            srt = Path(completed.output_params[\"captions_path\"]).read_text(encoding=\"utf-8\")\n460\t            self.assertIn(\"00:00:00,000 -->\", srt)\n461\t            self.assertIn(\"00:00:08,000\", srt)\n462\t\n463\t    def test_timed_tts_generates_one_clip_per_segment_group(self) -> None:\n464\t        # Use the real MOV so LocalRenderModule.probe_media succeeds.\n465\t        # The voice module probes it to get the actual video duration for timed positioning.\n466\t        with tempfile.TemporaryDirectory() as tmp, \\\n467\t             patch(\"core.workflows.modules.say_is_available\", return_value=True), \\\n468\t             patch(\"core.workflows.modules.create_say_audio\") as mock_say, \\\n469\t             patch(\"core.workflows.modules.run_render_command\"):\n470\t            root = Path(tmp)\n471\t            store = JsonJobStore(root / \"jobs\")\n472\t            runner = WorkflowRunner(\n473\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n474\t                default_modules(),\n475\t                store,\n476\t                output_root=root / \"output\",\n477\t            )\n478\t            job = runner.start(\n479\t                \"demo\",\n480\t                {\n481\t                    \"input_path\": \"input/IMG_2157.MOV\",\n482\t                    \"transcript\": \"Welcome inside. The kitchen is bright.\",\n483\t                    \"transcript_segments\": [\n484\t                        {\"start\": 0.0, \"end\": 2.0, \"text\": \"Welcome inside.\"},\n485\t                        {\"start\": 5.0, \"end\": 8.0, \"text\": \"The kitchen is bright.\"},\n486\t                    ],\n487\t                },\n488\t            )\n489\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n490\t            approval_path.write_text(\"Welcome inside. The kitchen is bright.\", encoding=\"utf-8\")\n491\t            completed = runner.approve(job.id, approval_path)\n492\t\n493\t        self.assertEqual(completed.output_params[\"voice_provider\"], \"local_say_timed\")\n494\t        # One say call per segment group (two distinct segments → two clips)\n495\t        self.assertEqual(mock_say.call_count, 2)\n496\t        # Narration duration should equal the probed video duration (> 8s fallback estimate)\n497\t        self.assertGreater(completed.output_params[\"narration_duration_seconds\"], 8.0)\n498\t\n499\t    def test_elevenlabs_tts_is_used_when_provider_is_elevenlabs(self) -> None:\n500\t        mock_el_client = MagicMock()\n501\t        mock_el_client.synthesize_to_file.side_effect = lambda text, path: Path(path).write_bytes(b\"\")\n502\t\n503\t        with tempfile.TemporaryDirectory() as tmp, \\\n504\t             patch(\"core.workflows.modules.run_render_command\"), \\\n505\t             patch(\"core.workflows.modules._apply_clip_speeds\"):\n506\t            root = Path(tmp)\n507\t            store = JsonJobStore(root / \"jobs\")\n508\t            runner = WorkflowRunner(\n509\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n510\t                default_modules(tts_client=mock_el_client),\n511\t                store,\n512\t                output_root=root / \"output\",\n513\t            )\n514\t            job = runner.start(\n515\t                \"demo\",\n516\t                {\n517\t                    \"input_path\": \"input/IMG_2157.MOV\",\n518\t                    \"transcript\": \"Welcome inside. The kitchen is bright.\",\n519\t                    \"transcript_segments\": [\n520\t                        {\"start\": 0.0, \"end\": 2.0, \"text\": \"Welcome inside.\"},\n521\t                        {\"start\": 5.0, \"end\": 8.0, \"text\": \"The kitchen is bright.\"},\n522\t                    ],\n523\t                    \"local_tts_provider\": \"elevenlabs\",\n524\t                },\n525\t            )\n526\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n527\t            approval_path.write_text(\"Welcome inside. The kitchen is bright.\", encoding=\"utf-8\")\n528\t            completed = runner.approve(job.id, approval_path)\n529\t\n530\t        self.assertEqual(completed.output_params[\"voice_provider\"], \"elevenlabs_timed\")\n531\t        self.assertEqual(mock_el_client.synthesize_to_file.call_count, 2)\n532\t        # Confirm say was never invoked\n533\t        first_call_text = mock_el_client.synthesize_to_file.call_args_list[0][0][0]\n534\t        self.assertIn(\"Welcome inside\", first_call_text)\n535\t\n536\t    def test_apply_clip_speeds_sets_speed_when_clip_overruns_next_start(self) -> None:\n537\t        clips = [\n538\t            {\"path\": \"tmp/seg_000.aiff\", \"start_seconds\": 0.0},\n539\t            {\"path\": \"tmp/seg_001.aiff\", \"start_seconds\": 3.0},\n540\t            {\"path\": \"tmp/seg_002.aiff\", \"start_seconds\": 8.0},\n541\t        ]\n542\t        # First clip is 5s long but only has a 3s window → raw ratio 1.67, capped at 1.3\n543\t        # Second clip is 2s long and has a 5s window → no speed adjustment needed\n544\t        # Third clip is 1s long, last clip, window is total_duration(10) - 8 = 2s → fits\n545\t        def fake_probe(path):\n546\t            durations = {\"tmp/seg_000.aiff\": 5.0, \"tmp/seg_001.aiff\": 2.0, \"tmp/seg_002.aiff\": 1.0}\n547\t            m = MagicMock()\n548\t            m.duration_seconds = durations[str(path)]\n549\t            return m\n550\t\n551\t        with patch(\"core.workflows.modules.probe_media\", side_effect=fake_probe):\n552\t            _apply_clip_speeds(clips, total_duration=10.0)\n553\t\n554\t        self.assertAlmostEqual(clips[0][\"speed\"], 1.3, places=4)\n555\t        self.assertNotIn(\"speed\", clips[1])\n556\t        self.assertNotIn(\"speed\", clips[2])\n557\t\n558\t    def test_apply_clip_speeds_caps_speed_at_1_3x(self) -> None:\n559\t        clips = [\n560\t            {\"path\": \"tmp/seg_000.aiff\", \"start_seconds\": 0.0},\n561\t            {\"path\": \"tmp/seg_001.aiff\", \"start_seconds\": 1.0},\n562\t        ]\n563\t        # Clip is 10s long but only a 1s window → raw ratio is 10x, should cap at 1.3\n564\t        def fake_probe(path):\n565\t            m = MagicMock()\n566\t            m.duration_seconds = 10.0 if \"seg_000\" in str(path) else 0.5\n567\t            return m\n568\t\n569\t        with patch(\"core.workflows.modules.probe_media\", side_effect=fake_probe):\n570\t            _apply_clip_speeds(clips, total_duration=5.0)\n571\t\n572\t        self.assertAlmostEqual(clips[0][\"speed\"], 1.3)\n573\t\n574\t    def test_tts_grouping_never_splits_a_sentence_across_clips(self) -> None:\n575\t        from core.workflows.modules import _group_text_for_timed_tts\n576\t\n577\t        # Long sentence spans multiple proportional buckets under chunk-based mapping.\n578\t        # Sentence-level grouping must keep it whole in a single TTS clip.\n579\t        segments = [\n580\t            {\"start\": 0.0, \"end\": 3.0, \"text\": \"Intro.\"},\n581\t            {\"start\": 4.0, \"end\": 11.0, \"text\": \"Middle segment.\"},\n582\t            {\"start\": 12.0, \"end\": 15.0, \"text\": \"Outro.\"},\n583\t        ]\n584\t        text = (\n585\t            \"Short intro. \"\n586\t            \"It is a really wonderful home with many great features for a growing family. \"\n587\t            \"Short outro.\"\n588\t        )\n589\t        groups = _group_text_for_timed_tts(text, segments)\n590\t\n591\t        # The long middle sentence must appear entirely within a single group\n592\t        all_texts = [g[\"text\"] for g in groups]\n593\t        long_sentence = \"It is a really wonderful home with many great features for a growing family.\"\n594\t        self.assertTrue(\n595\t            any(long_sentence in t for t in all_texts),\n596\t            f\"Long sentence was split across clips. Groups: {all_texts}\",\n597\t        )\n598\t\n599\t    def test_caption_segments_do_not_overlap_when_chunks_exceed_segment_count(self) -> None:\n600\t        from core.workflows.modules import _caption_segments_from_whisper\n601\t\n602\t        # 2 whisper segments, many caption chunks — historically caused duplicate timestamps\n603\t        segments = [\n604\t            {\"start\": 0.0, \"end\": 8.0, \"text\": \"First long sentence with many words here.\"},\n605\t            {\"start\": 9.0, \"end\": 12.0, \"text\": \"Second sentence.\"},\n606\t        ]\n607\t        text = (\n608\t            \"First long sentence with many words here. \"\n609\t            \"Second sentence that also has quite a few words in it.\"\n610\t        )\n611\t        result = _caption_segments_from_whisper(text, segments, narration_duration=12.0)\n612\t\n613\t        starts = [seg[\"start\"] for seg in result]\n614\t        ends = [seg[\"end\"] for seg in result]\n615\t        # Every start must be strictly less than the corresponding end\n616\t        for i, (s, e) in enumerate(zip(starts, ends)):\n617\t            self.assertLess(s, e, f\"Segment {i} has zero or negative duration: {s}–{e}\")\n618\t        # No two adjacent segments should have the same start time\n619\t        for i in range(len(starts) - 1):\n620\t            self.assertLess(starts[i], starts[i + 1], f\"Segments {i} and {i+1} share start time {starts[i]}\")\n621\t        # Last segment must end at narration_duration\n622\t        self.assertAlmostEqual(ends[-1], 12.0)\n623\t\n624\t    def test_transcript_cleanup_removes_duplicate_words_and_i_mean(self) -> None:\n625\t        with tempfile.TemporaryDirectory() as tmp:\n626\t            root = Path(tmp)\n627\t            store = JsonJobStore(root / \"jobs\")\n628\t            runner = WorkflowRunner(\n629\t                load_workflow_config(\"workflows/video_narration/config.yaml\"),\n630\t                default_modules(),\n631\t                store,\n632\t                output_root=root / \"output\",\n633\t            )\n634\t            job = runner.start(\n635\t                \"demo\",\n636\t                {\n637\t                    \"input_path\": \"input/IMG_2157.MOV\",\n638\t                    \"transcript\": \"I mean, the the kitchen is nice. And the living room.\",\n639\t                },\n640\t            )\n641\t            approval_path = Path(job.output_params[\"approval_artifact\"])\n642\t            approval_text = approval_path.read_text(encoding=\"utf-8\")\n643\t\n644\t        self.assertNotIn(\"I mean\", approval_text)\n645\t        self.assertNotIn(\"the the\", approval_text)\n646\t        self.assertIn(\"The kitchen\", approval_text)\n647\t\n648\t\n649\tif __name__ == \"__main__\":\n650\t    unittest.main()\n651\t"}]},"uuid":"ca29164b-4146-498c-bc23-0948c6d7cd1c","timestamp":"2026-07-21T02:45:56.097Z","sourceToolAssistantUUID":"4511d565-8676-4f1f-ad3b-330941314666","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
{"parentUuid":"ca29164b-4146-498c-bc23-0948c6d7cd1c","isSidechain":true,"agentId":"a64b6f12d31791766","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CdESJ9VLuDGDnqhzhQSXu","type":"message","role":"assistant","content":[{"type":"text","text":"Perfect! Now I have enough information. Let me create a comprehensive summary document:\n\n## Summary: StageHand (CreatorFlow) Pipeline Structure Analysis\n\nBased on my thorough exploration of the codebase, here's the complete architecture for implementing an outer QA→fail→rerender retry loop:\n\n### Key File Paths and Roles\n\n**Pipeline Entry Points & Configuration:**\n- `/workspace/extra/projects/stagehand/cli/main.py` - Main CLI entry point with all commands\n- `/workspace/extra/projects/stagehand/core/workflows/runner.py` - Core workflow orchestration engine\n- `/workspace/extra/projects/stagehand/core/workflows/config.py` - Workflow config loader (YAML/JSON)\n- `/workspace/extra/projects/stagehand/core/workflows/store.py` - Job persistence (JSON-based)\n\n**Workflow Definitions:**\n- `/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml` - Listing reel workflow (JSON format, not YAML)\n- `/workspace/extra/projects/stagehand/workflows/video_narration/config.yaml` - Video narration workflow\n- `/workspace/extra/projects/stagehand/workflows/publish_instagram/config.yaml` - Publishing workflow\n\n**QA/Review Modules:**\n- `/workspace/extra/projects/stagehand/core/listings/scorecard.py` - Scoring logic with `score_pre_render()` and `score_post_render()` functions\n- `/workspace/extra/projects/stagehand/core/hyperframes_review.py` - HyperFrames video review (generates scores, recommendations)\n- `/workspace/extra/projects/stagehand/core/listings/review.py` - Listing review bundle building\n- `/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py` - Listing-specific workflow modules including `ListingVideoReviewModule`, `ListingScriptReviewModule`\n\n**Rendering Modules:**\n- `/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py` - `ListingPhotoReelRenderModule` (line 182) - renders listing reels\n- `/workspace/extra/projects/stagehand/core/media/photo_reel.py` - Low-level photo reel rendering\n- `/workspace/extra/projects/stagehand/core/workflows/modules.py` - `LocalRenderModule` (legacy video narration rendering)\n\n**State Management:**\n- `/workspace/extra/projects/stagehand/core/models.py` - `Job`, `JobStatus`, `ApprovalStatus`, `JobStep`, `StepStatus` enums\n\n---\n\n### Current Pipeline Flow\n\n**Listing Reel Workflow (canonical engine per CLAUDE.md):**\n\n```\n1. intake                      → ListingIntakeModule\n2. draft_review               → ListingReviewDraftModule\n3. approve_listing_reel       → ApprovalGate (WAITING_FOR_APPROVAL)\n4. validate_approval          → ListingReviewApprovalModule\n5. script_review             → ListingScriptReviewModule (pre-render QA scoring)\n6. synthesize_voice          → LocalVoiceModule\n7. build_captions            → LocalCaptionModule\n8. build_graphics            → ListingGraphicsModule\n9. render_reel               → ListingPhotoReelRenderModule (RENDERS VIDEO)\n10. video_review             → ListingVideoReviewModule (post-render QA scoring) ← KEY STEP\n11. build_review_artifacts   → ListingReviewArtifactsModule\n12. package_instagram        → InstagramPackageModule\n```\n\nThe workflow is defined as a linear list of steps in `/workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml` (JSON format, despite the .yaml extension).\n\n---\n\n### How QA Failure is Communicated\n\n**Score/QA Results:**\n\n- `ListingVideoReviewModule` (line 235 in listing_reel_modules.py) runs after rendering\n- Calls `score_post_render()` from scorecard.py (line 146), which generates a `ScoreReport` dataclass:\n  - `overall_score: int` (0-100)\n  - `recommendation: str` (\"publishable\", \"revise\", \"reject\")\n  - `hard_gate_warnings: list[dict]` - list of failures with severity \"error\"\n  - `top_issues: list[dict]` - list of warnings/issues\n\n- **Key**: The module returns a dict with `video_review`, `post_render_score`, and `post_render_score_path`\n- **No automatic failure mechanism** - the scoring happens, data is stored in `context.data`, but there's no built-in logic to fail the job or retry based on score\n\n**HyperFrames Review (alternative review path for HyperFrames projects):**\n- `review_hyperframes_project()` in hyperframes_review.py generates:\n  - `overall_score: int`\n  - `recommendation: str` (\"publishable\", \"revise\", \"reject\")\n  - `hard_failures: list` - items with severity \"error\"\n  \n---\n\n### How Rendering is Invoked\n\n**Method 1: Automatic during workflow (if `execute_render: true`):**\n```python\n# In ListingPhotoReelRenderModule.run() (line 182 of listing_reel_modules.py)\nif bool(context.data.get(\"execute_render\", False)):\n    run_photo_reel(command)  # Actually runs ffmpeg\n    probe = probe_media(output_path)\n    result.update({\n        \"render_executed\": True,\n        \"rendered_duration_seconds\": probe.duration_seconds,\n        # ... other metrics\n    })\n```\n\n**Method 2: CLI-invoked rendering:**\n```bash\npython -m cli.main listing-reel \\\n  --url \"https://compass.com/property/...\" \\\n  --manifest path/to/manifest.json \\\n  # ... other args (execute_render is True by default for listing-reel)\n```\n\n**Method 3: Post-approval rendering via `resume`:**\n```bash\npython -m cli.main resume <job_id>  # Resumes from failed step\n```\n\n**Rendering command construction:**\n- `ListingPhotoReelRenderModule` calls `build_photo_reel_command()` (from core/media/photo_reel.py)\n- Command is stored in `context.data[\"render_command\"]` (list of strings)\n- Actual execution: `run_photo_reel(command)` (subprocess wrapper)\n\n---\n\n### Existing Retry Handling\n\n**Current retry mechanism (limited):**\n\n1. **Resume after failure** (`runner.resume(job_id)`):\n   - Located in `/workspace/extra/projects/stagehand/core/workflows/runner.py` (line 117)\n   - Requirements:\n     - Job must have `status == JobStatus.FAILED`\n     - Job must have `approval_status == ApprovalStatus.APPROVED`\n   - Behavior:\n     - Calls `self.run(job_id)` which re-executes the workflow from the first non-completed step\n     - Does NOT re-execute already completed steps (tracks `completed_steps` at line 64)\n\n2. **Test evidence** (test_workflow_runner.py):\n   - Lines 17-68: `test_failed_post_approval_step_is_persisted_and_can_resume()`\n   - Demonstrates: step fails → job saved with FAILED status → external code fixes issue (flaky.should_fail = False) → resume succeeds\n\n3. **No automatic retry**:\n   - No built-in loop on QA failure\n   - No mechanism to automatically re-render if score is below threshold\n   - No configurable retry count or backoff strategy\n\n---\n\n### Workflow Config Format\n\n**Format:** JSON (despite .yaml extensions)\n\n**Example (listing_reel/config.yaml):**\n```json\n{\n  \"name\": \"listing_reel\",\n  \"description\": \"...\",\n  \"steps\": [\n    {\n      \"id\": \"intake\",\n      \"module\": \"listing_intake\",\n      \"params\": {}\n    },\n    {\n      \"id\": \"render_reel\",\n      \"module\": \"listing_photo_reel\",\n      \"params\": {\n        \"width\": 1080,\n        \"height\": 1920,\n        \"fps\": 60,\n        \"outro_duration_seconds\": 4.5\n      }\n    },\n    {\n      \"id\": \"video_review\",\n      \"module\": \"listing_video_review\",\n      \"params\": {}\n    }\n  ]\n}\n```\n\n**Structure:**\n- `name`: workflow identifier\n- `description`: human-readable description\n- `steps`: array of `WorkflowStep` objects (id, module, params)\n\n**Parsing:**\n- `load_workflow_config()` in config.py (line 23):\n  - Tries YAML first (via `yaml.safe_load()`)\n  - Falls back to JSON if YAML import fails\n  - Creates `WorkflowStep` namedtuples with frozen dataclass semantics\n\n---\n\n### Job State Management Details\n\n**Job Status Enum** (core/models.py):\n```python\nPENDING, RUNNING, WAITING_FOR_APPROVAL, COMPLETED, FAILED\n```\n\n**Approval Status Enum:**\n```python\nNOT_REQUIRED, PENDING, APPROVED, REJECTED\n```\n\n**Step Status Enum:**\n```python\nPENDING, RUNNING, COMPLETED, WAITING_FOR_APPROVAL, FAILED, SKIPPED\n```\n\n**Persistence:**\n- JSON files in `output/jobs/<job_id>.json`\n- Each step tracked with:\n  - `step_id`, `status`, `input_data`, `output_data`\n  - `approved_by`, `approved_at`, `duration_ms`\n- Context data merged across steps: `context.data = {**job.input_params, **job.output_params}`\n\n**Resume logic** (runner.py line 64-65):\n```python\ncompleted_steps = {step.step_id for step in job.steps if step.status == StepStatus.COMPLETED}\nfor workflow_step in self.config.steps:\n    if workflow_step.id in completed_steps:\n        continue  # Skip already-completed steps\n```\n\n---\n\n### Implementation Strategy for QA→Fail→Rerender Loop\n\n**Proposed approach:**\n\n1. **Create a new retry wrapper module** that sits between `video_review` and `build_review_artifacts`:\n   - Check if `overall_score` or `hard_gate_warnings` indicate failure\n   - If threshold not met: roll back to a previous step (e.g., `synthesize_voice` or `render_reel`)\n   - Re-execute the rendering pipeline with modified params or flags\n   - Re-run QA\n   - Increment retry counter\n\n2. **Modify workflow config** to include a conditional retry step:\n   - Add retry metadata to workflow steps\n   - Or create a separate \"retry_reel\" workflow variant\n\n3. **Leverage existing resume mechanism**:\n   - Don't try to retry within a single job execution\n   - Instead, programmatically call `runner.resume()` if QA fails\n   - Or update job state to re-run from a specific step\n\n4. **Key decision point**: Whether to:\n   - Modify the job in-place (skip completed steps, re-execute from retry point)\n   - Create a new job with updated params\n   - Use a separate retry loop external to the workflow runner\n\n**Key files to modify:**\n- Add a `ListingQARetryModule` in listing_reel_modules.py that checks scores and manages retry logic\n- Optionally modify runner.py to support step re-execution (currently blocks re-running completed steps)\n- Potentially update listing_reel/config.yaml to add retry step configuration\n\nThe existing `resume()` mechanism already handles the hard part (re-running from a specific step while skipping completed ones). You'll need to wire it up to the QA scoring step."}],"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"usage":{"input_tokens":6,"cache_creation_input_tokens":9926,"cache_read_input_tokens":52998,"output_tokens":2914,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":9926},"inference_geo":"","iterations":[{"input_tokens":6,"output_tokens":2914,"cache_read_input_tokens":52998,"cache_creation_input_tokens":9926,"cache_creation":{"ephemeral_5m_input_tokens":9926,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"diagnostics":null},"requestId":"req_011CdESJ8HR6EwTK7fbjidSZ","attributionAgent":"Explore","type":"assistant","uuid":"a5b0763b-a68c-48da-8ad5-6169800823d1","timestamp":"2026-07-21T02:46:28.932Z","userType":"external","entrypoint":"sdk-ts","cwd":"/workspace/agent","sessionId":"033a4a00-9171-4e08-9a79-6b99f177355d","version":"2.1.128","gitBranch":"HEAD","slug":"mellow-wobbling-swan"}
