diff --git a/README.md b/README.md index d420871..dd22d17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GrokBoy -Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn agent REPL (dialogue + tools). +Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + confirm-before-post (`request_user_confirm`). ## Status @@ -12,6 +12,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn a | P3 browser (Playwright DOM) | done (optional) | | P4 human handoff | done | | P5 interactive agent REPL | done | +| P6 scenario playbooks + confirm | done | No Docker desktop, no Codex/LazyBoy fork. Product notes: [`docs/PRODUCT.md`](docs/PRODUCT.md). @@ -60,6 +61,24 @@ cargo run -p grokboy -- agent Tests / CI: `GROKBOY_HANDOFF_AUTO=1` auto-resumes (no interactive Enter). + +### Scenario playbooks (P6) + +Reusable acceptance pattern: **Phase A** research+draft (no publish) → auth `browser_handoff` + **`request_user_confirm`** → **Phase B** publish only after approve. + +- Pattern: [`docs/scenarios/README.md`](docs/scenarios/README.md) +- Template: [`docs/SCENARIO-TEMPLATE.md`](docs/SCENARIO-TEMPLATE.md) · prompts in `prompts/templates/` +- Example only: Shopee Affiliate → Threads — [`docs/scenarios/examples/shopee-threads-affiliate.md`](docs/scenarios/examples/shopee-threads-affiliate.md) + +```bash +export GROKBOY_BROWSER_HEADED=1 +cargo run -p grokboy -- agent +# paste prompts/examples/shopee-threads-phase-a.txt (or your filled template) +# review draft → paste phase-b or say「核准,請發佈…」 +``` + +Tests: `GROKBOY_CONFIRM_AUTO=1` auto-approves; `abort` denies. + ## Commands - `grokboy chat` — interactive streaming chat (no tools) @@ -74,7 +93,7 @@ In `agent` REPL: `/exit` or `/quit` leave; `/session` show id; empty line ignore Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`, `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval`, -`browser_handoff`. +`browser_handoff`, `request_user_confirm`. Sessions are stored under `~/.grokboy/sessions/.json` (may include `last_browser_url`). @@ -82,7 +101,7 @@ The agent stops on `report_done` / `report_blocked`, blocks identical tool round ## Traditional Chinese -本機終端機 coding assistant。P5 支援互動式多輪代理(含工具):`grokboy agent`。P4 瀏覽器人工接手(登入/OTP/驗證碼)在 `run` / `agent` 內皆可用。 +本機終端機 coding assistant。P6 支援可推廣的情境劇本(Phase A 草稿 → confirm → Phase B 發佈)與 `request_user_confirm`。P5 `grokboy agent`;P4 `browser_handoff`(登入牆)。範例:蝦皮→Threads,見 `docs/scenarios/`。 ```bash export GROKBOY_API_KEY=你的金鑰 @@ -104,4 +123,7 @@ crates/grokboy/ # CLI binary tools/playwright/ # optional Node Playwright helper (JSONL) docs/PRODUCT.md docs/ACCEPTANCE.md +docs/scenarios/ # playbook pattern + examples +prompts/templates/ # Phase A/B placeholders +prompts/examples/ # filled example prompts ``` diff --git a/crates/grokboy-core/src/agent.rs b/crates/grokboy-core/src/agent.rs index 5fbb984..88b40bc 100644 --- a/crates/grokboy-core/src/agent.rs +++ b/crates/grokboy-core/src/agent.rs @@ -15,9 +15,10 @@ pub const AGENT_SYSTEM: &str = "\ You are GrokBoy, a concise local coding assistant with tools. Use tools when they help solve the task; otherwise answer directly. Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese. -Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked, and optional browser_* (Playwright DOM: browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, browser_handoff). +Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked, request_user_confirm, and optional browser_* (Playwright DOM: browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, browser_handoff). For web pages prefer DOM snapshot + selector/role click/type — not screenshots or pixel XY clicks. -If you hit a login / OTP / captcha wall you cannot pass alone, call browser_handoff with a clear reason so the human can help in the visible browser, then continue from the returned snapshot. +If you hit a login / OTP / captcha wall you cannot pass alone, call browser_handoff with a clear reason so the human can help in the visible browser, then continue from the returned snapshot. Prefer browser_handoff only for auth walls. +Never publish/send social posts (Threads, Facebook, Instagram, X/Twitter, etc.) or take other irreversible public actions without either (a) an explicit user message this turn approving the exact draft, or (b) request_user_confirm returning approved. Prefer draft → confirm → then act. If approval is missing, call request_user_confirm (with the draft in prompt) or report_blocked — never post unilaterally. When the task is finished, call report_done with a short summary. If you are stuck or cannot proceed, call report_blocked with the reason — do not invent results or loop. Do not invent tool results — call the tools."; @@ -314,8 +315,10 @@ mod tests { assert!(AGENT_SYSTEM.contains("shell")); assert!(AGENT_SYSTEM.contains("report_done")); assert!(AGENT_SYSTEM.contains("report_blocked")); + assert!(AGENT_SYSTEM.contains("request_user_confirm")); assert!(AGENT_SYSTEM.contains("browser_navigate") || AGENT_SYSTEM.contains("browser_")); assert!(AGENT_SYSTEM.contains("browser_handoff")); + assert!(AGENT_SYSTEM.contains("irreversible") || AGENT_SYSTEM.contains("Never publish")); assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese")); } diff --git a/crates/grokboy-core/src/confirm.rs b/crates/grokboy-core/src/confirm.rs new file mode 100644 index 0000000..8d8127d --- /dev/null +++ b/crates/grokboy-core/src/confirm.rs @@ -0,0 +1,362 @@ +//! Human confirmation gate before irreversible public actions (P6). +//! +//! `request_user_confirm` prints a bilingual banner and waits for stdin: +//! `yes` / `y` / Enter → approve; `no` / `abort` → deny; timeout → deny (fail-closed). +//! +//! Env (tests / CI): +//! - `GROKBOY_CONFIRM_AUTO=1|yes|approve` → approve without stdin +//! - `GROKBOY_CONFIRM_AUTO=abort|no|0` → deny without stdin +//! - Falls back to the same values on `GROKBOY_HANDOFF_AUTO` when CONFIRM_AUTO is unset + +use anyhow::{Result, anyhow}; +use serde_json::{Value, json}; +use std::io::{BufRead, Write}; +use std::sync::mpsc; +use std::time::Duration; + +/// Outcome of waiting for human confirmation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfirmWait { + Approved, + Denied(String), + TimedOut, +} + +const DEFAULT_CONFIRM_TIMEOUT_SECS: u64 = 300; + +/// OpenAI-compatible tool definition for `request_user_confirm`. +pub fn confirm_tool_definition() -> Value { + json!({ + "type": "function", + "function": { + "name": "request_user_confirm", + "description": "Ask the human to approve an irreversible public action (e.g. publishing a Threads/FB post, sending a message). Prints a bilingual banner and waits for yes/y/Enter (approve) or no/abort (deny). Prefer draft → confirm → then act. Do NOT publish social posts without either an explicit user message this turn approving the exact draft, or this tool returning approved.", + "parameters": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Why confirmation is needed (e.g. about to publish Threads post)" + }, + "prompt": { + "type": "string", + "description": "Optional text to show the user (e.g. the exact draft to approve)" + }, + "timeout_secs": { + "type": "integer", + "description": "Seconds to wait (default 300)" + } + }, + "required": ["reason"] + } + } + }) +} + +/// Resolve auto-approve / auto-deny from env (CONFIRM_AUTO first, then HANDOFF_AUTO). +fn confirm_auto_from_env() -> Option { + for key in ["GROKBOY_CONFIRM_AUTO", "GROKBOY_HANDOFF_AUTO"] { + if let Ok(v) = std::env::var(key) { + let v = v.trim().to_ascii_lowercase(); + if v.is_empty() { + continue; + } + if v == "1" + || v == "true" + || v == "yes" + || v == "y" + || v == "approve" + || v == "approved" + || v == "resume" + || v == "continue" + { + return Some(ConfirmWait::Approved); + } + if v == "abort" + || v == "0" + || v == "false" + || v == "no" + || v == "n" + || v == "deny" + || v == "denied" + { + return Some(ConfirmWait::Denied(format!("{key}={v}"))); + } + } + } + None +} + +/// Wait for stdin confirmation or timeout. +/// +/// Approve: empty Enter, `yes`, `y`.\n +/// Deny: `no`, `n`, `abort`, `q`, `quit`, `cancel`. +pub fn wait_for_user_confirm(timeout_secs: u64) -> ConfirmWait { + if let Some(auto) = confirm_auto_from_env() { + return auto; + } + + let (tx, rx) = mpsc::channel::>(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + let mut line = String::new(); + match stdin.lock().read_line(&mut line) { + Ok(0) => { + let _ = tx.send(Err("stdin closed (EOF)".into())); + } + Ok(_) => { + let _ = tx.send(Ok(line)); + } + Err(e) => { + let _ = tx.send(Err(format!("stdin read error: {e}"))); + } + } + }); + + match rx.recv_timeout(Duration::from_secs(timeout_secs.max(1))) { + Ok(Ok(line)) => { + let t = line.trim().to_ascii_lowercase(); + if t.is_empty() || t == "yes" || t == "y" { + ConfirmWait::Approved + } else if t == "no" + || t == "n" + || t == "abort" + || t == "q" + || t == "quit" + || t == "cancel" + || t == "deny" + { + ConfirmWait::Denied(format!("user typed {t}")) + } else { + // Unknown input → treat as deny (fail-closed for irreversible actions). + ConfirmWait::Denied(format!( + "unrecognized input {t:?}; type yes/y/Enter to approve, no/abort to deny" + )) + } + } + Ok(Err(msg)) => ConfirmWait::Denied(msg), + Err(mpsc::RecvTimeoutError::Timeout) => ConfirmWait::TimedOut, + Err(mpsc::RecvTimeoutError::Disconnected) => { + ConfirmWait::Denied("confirm wait thread disconnected".into()) + } + } +} + +fn print_confirm_banner(reason: &str, prompt: Option<&str>, timeout_secs: u64) { + let draft_block = match prompt { + Some(p) if !p.trim().is_empty() => format!( + " +【待確認內容 / Draft to approve】 +{p} +" + ), + _ => String::new(), + }; + let banner = format!( + " +╔══════════════════════════════════════════════════════════════╗ +║ GrokBoy P6 — Confirm before post / 發文前記者確認 ║ +╚══════════════════════════════════════════════════════════════╝ + +【為什麼需要確認 / Why confirm】 + {reason} +{draft_block} +【請你做什麼 / What to do】 + 核准:輸入 yes 或 y,或直接按 Enter。 + Type yes / y, or just press Enter to approve. + 拒絕:輸入 no 或 abort 再按 Enter。 + Type no / abort then Enter to deny. + + Timeout / 逾時: {timeout_secs}s(逾時視為拒絕 / timeout = deny) + (tests: GROKBOY_CONFIRM_AUTO=1 to approve; =abort to deny + or GROKBOY_HANDOFF_AUTO=1 / abort) + +─── waiting for yes / 等待確認 ─── +" + ); + eprintln!("{banner}"); + let _ = std::io::stderr().flush(); +} + +/// Execute `request_user_confirm` from tool arguments. +pub fn execute_request_user_confirm(args: &Value) -> Result { + let reason = args + .get("reason") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("request_user_confirm: missing 'reason'"))? + .to_string(); + + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let timeout_secs = args + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .filter(|&n| n > 0) + .unwrap_or(DEFAULT_CONFIRM_TIMEOUT_SECS); + + // Run blocking stdin wait off the async runtime when called from async tools. + let reason_clone = reason.clone(); + let prompt_clone = prompt.clone(); + let wait = { + // Always print banner (even under AUTO) so smoke/logs show the gate fired. + print_confirm_banner( + &reason_clone, + prompt_clone.as_deref(), + timeout_secs, + ); + wait_for_user_confirm(timeout_secs) + }; + + match wait { + ConfirmWait::Approved => Ok(json!({ + "status": "approved", + "approved": true, + "reason": reason, + "prompt": prompt, + "message": "User approved. You may proceed with the irreversible action described in reason/prompt.", + })), + ConfirmWait::Denied(msg) => Ok(json!({ + "status": "denied", + "approved": false, + "denied": true, + "reason": format!("user confirm denied: {msg}"), + "original_reason": reason, + "prompt": prompt, + "message": "User denied. Do NOT publish/send. Call report_blocked or revise the draft.", + })), + ConfirmWait::TimedOut => Ok(json!({ + "status": "denied", + "approved": false, + "denied": true, + "timed_out": true, + "reason": format!( + "user confirm timed out after {timeout_secs}s (fail-closed = deny)" + ), + "original_reason": reason, + "prompt": prompt, + "message": "Confirm timed out. Do NOT publish/send. Call report_blocked.", + })), + } +} + +/// Async wrapper for tool dispatch (spawns blocking wait). +pub async fn execute_request_user_confirm_async(args: &Value) -> Result { + let args = args.clone(); + tokio::task::spawn_blocking(move || execute_request_user_confirm(&args)) + .await + .map_err(|e| anyhow!("request_user_confirm join: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn clear_confirm_env() { + unsafe { + std::env::remove_var("GROKBOY_CONFIRM_AUTO"); + // Do not remove HANDOFF_AUTO globally if other tests need it — set/restore carefully. + } + } + + #[test] + fn confirm_auto_approve_via_confirm_env() { + let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); + let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); + unsafe { + std::env::remove_var("GROKBOY_HANDOFF_AUTO"); + std::env::set_var("GROKBOY_CONFIRM_AUTO", "1"); + } + assert_eq!(wait_for_user_confirm(1), ConfirmWait::Approved); + unsafe { + std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort"); + } + match wait_for_user_confirm(1) { + ConfirmWait::Denied(msg) => assert!(msg.contains("abort"), "{msg}"), + other => panic!("expected Denied, got {other:?}"), + } + restore_env("GROKBOY_CONFIRM_AUTO", prev_c); + restore_env("GROKBOY_HANDOFF_AUTO", prev_h); + let _ = clear_confirm_env; + } + + #[test] + fn confirm_auto_falls_back_to_handoff_env() { + let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); + let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); + unsafe { + std::env::remove_var("GROKBOY_CONFIRM_AUTO"); + std::env::set_var("GROKBOY_HANDOFF_AUTO", "1"); + } + assert_eq!(wait_for_user_confirm(1), ConfirmWait::Approved); + unsafe { + std::env::set_var("GROKBOY_HANDOFF_AUTO", "abort"); + } + match wait_for_user_confirm(1) { + ConfirmWait::Denied(msg) => assert!(msg.contains("abort"), "{msg}"), + other => panic!("expected Denied, got {other:?}"), + } + restore_env("GROKBOY_CONFIRM_AUTO", prev_c); + restore_env("GROKBOY_HANDOFF_AUTO", prev_h); + } + + #[test] + fn execute_approve_and_deny_offline() { + let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); + let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); + unsafe { + std::env::remove_var("GROKBOY_HANDOFF_AUTO"); + std::env::set_var("GROKBOY_CONFIRM_AUTO", "1"); + } + let ok = execute_request_user_confirm(&json!({ + "reason": "publish Threads draft", + "prompt": "測試貼文內容", + "timeout_secs": 2 + })) + .unwrap(); + assert_eq!(ok["status"], "approved"); + assert_eq!(ok["approved"], true); + assert!(ok["prompt"].as_str().unwrap().contains("測試")); + + unsafe { + std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort"); + } + let no = execute_request_user_confirm(&json!({ + "reason": "publish Threads draft", + "timeout_secs": 2 + })) + .unwrap(); + assert_eq!(no["status"], "denied"); + assert_eq!(no["approved"], false); + + let bad = execute_request_user_confirm(&json!({})).unwrap_err(); + assert!(bad.to_string().contains("reason"), "{bad}"); + + restore_env("GROKBOY_CONFIRM_AUTO", prev_c); + restore_env("GROKBOY_HANDOFF_AUTO", prev_h); + } + + #[test] + fn tool_def_name() { + let d = confirm_tool_definition(); + assert_eq!(d["function"]["name"], "request_user_confirm"); + assert!(d["function"]["parameters"]["required"] + .as_array() + .unwrap() + .iter() + .any(|x| x.as_str() == Some("reason"))); + } + + fn restore_env(key: &str, prev: Option) { + match prev { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } +} diff --git a/crates/grokboy-core/src/lib.rs b/crates/grokboy-core/src/lib.rs index f092825..c6823ef 100644 --- a/crates/grokboy-core/src/lib.rs +++ b/crates/grokboy-core/src/lib.rs @@ -2,6 +2,7 @@ mod agent; mod browser; +mod confirm; mod config; mod model; mod session; @@ -13,6 +14,7 @@ pub use agent::{ run_agent_with, tool_call_signature, truncate_messages, }; pub use browser::{INSTALL_HINT as BROWSER_INSTALL_HINT, HandoffWait, browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume}; +pub use confirm::{ConfirmWait, confirm_tool_definition, execute_request_user_confirm, wait_for_user_confirm}; pub use config::Config; pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat}; pub use session::{Session, load_or_create, load_session, save_session, sessions_dir}; diff --git a/crates/grokboy-core/src/tools.rs b/crates/grokboy-core/src/tools.rs index 3600f15..f5d38e0 100644 --- a/crates/grokboy-core/src/tools.rs +++ b/crates/grokboy-core/src/tools.rs @@ -1,4 +1,5 @@ -//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4). +//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4), +//! confirm-before-post (P6). use anyhow::{Context, Result, anyhow}; use serde_json::{Value, json}; @@ -8,6 +9,7 @@ use std::time::Duration; use tokio::process::Command; use crate::browser::{self, LastUrlSlot}; +use crate::confirm; pub const MAX_READ_BYTES: usize = 256 * 1024; pub const SHELL_TIMEOUT_SECS: u64 = 30; @@ -133,8 +135,9 @@ pub fn tool_definitions() -> Value { } } ]); - // Optional Playwright DOM tools (always registered; fail closed with install hint if missing). + // Confirm-before-post (P6) + optional Playwright DOM tools. if let Some(arr) = defs.as_array_mut() { + arr.push(confirm::confirm_tool_definition()); arr.extend(browser::browser_tool_definitions()); } defs @@ -205,6 +208,7 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str) "write_file" => tool_write_file(ctx, &args).await, "report_done" => tool_report_done(&args), "report_blocked" => tool_report_blocked(&args), + "request_user_confirm" => confirm::execute_request_user_confirm_async(&args).await, name if browser::is_browser_tool(name) => { browser::execute_browser_tool(&ctx.cwd, &ctx.last_browser_url, name, &args).await } @@ -496,7 +500,7 @@ mod tests { fn tool_defs_include_core_and_browser() { let defs = tool_definitions(); let arr = defs.as_array().unwrap(); - assert_eq!(arr.len(), 12); // 6 core + 6 browser (incl. handoff) + assert_eq!(arr.len(), 13); // 6 core + confirm + 6 browser (incl. handoff) let names: Vec<&str> = arr .iter() .map(|t| t["function"]["name"].as_str().unwrap()) @@ -507,6 +511,7 @@ mod tests { assert!(names.contains(&"write_file")); assert!(names.contains(&"report_done")); assert!(names.contains(&"report_blocked")); + assert!(names.contains(&"request_user_confirm")); assert!(names.contains(&"browser_navigate")); assert!(names.contains(&"browser_snapshot")); assert!(names.contains(&"browser_click")); @@ -546,6 +551,56 @@ mod tests { } } + #[tokio::test] + async fn request_user_confirm_auto_approve_and_deny() { + let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); + let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok(); + unsafe { + std::env::remove_var("GROKBOY_HANDOFF_AUTO"); + std::env::set_var("GROKBOY_CONFIRM_AUTO", "1"); + } + let (ctx, dir) = temp_ctx(); + let out = execute_tool( + &ctx, + "request_user_confirm", + &json!({ + "reason": "publish example post", + "prompt": "草稿內容", + "timeout_secs": 2 + }) + .to_string(), + ) + .await; + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["status"], "approved"); + assert_eq!(v["approved"], true); + + unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort") }; + let out2 = execute_tool( + &ctx, + "request_user_confirm", + &json!({"reason": "publish example post", "timeout_secs": 2}).to_string(), + ) + .await; + let v2: Value = serde_json::from_str(&out2).unwrap(); + assert_eq!(v2["status"], "denied"); + assert_eq!(v2["approved"], false); + + let bad = execute_tool(&ctx, "request_user_confirm", &json!({}).to_string()).await; + let bad: Value = serde_json::from_str(&bad).unwrap(); + assert!(bad.get("error").is_some(), "{bad}"); + + match prev_c { + Some(v) => unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", v) }, + None => unsafe { std::env::remove_var("GROKBOY_CONFIRM_AUTO") }, + } + match prev_h { + Some(v) => unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", v) }, + None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") }, + } + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn browser_tool_fails_closed_without_chromium_ok_with_helper() { // Missing required args should fail closed via helper protocol (no Chromium needed). diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index fcc6abc..1135775 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -51,7 +51,7 @@ async fn run() -> Result<()> { fn print_help() { println!( "\ -GrokBoy — minimal local CLI agent (P5: interactive multi-turn agent) +GrokBoy — minimal local CLI agent (P6: confirm-before-post + scenario playbooks) USAGE: grokboy chat Interactive streaming chat (no tools) @@ -70,10 +70,12 @@ ENV: GROKBOY_CONTEXT_CHARS context budget (default 100000) GROKBOY_BROWSER_HEADED 1 = always launch Chromium headed (visible; for run/agent) GROKBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort + GROKBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny + (falls back to HANDOFF_AUTO if unset) Tools: shell, list_dir, read_file, write_file, report_done, report_blocked, - browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, - browser_handoff + request_user_confirm, browser_navigate, browser_snapshot, browser_click, + browser_type, browser_eval, browser_handoff Sessions: ~/.grokboy/sessions/.json Browser (optional): cd tools/playwright && npm i && npx playwright install chromium Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter @@ -317,7 +319,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> { } async fn cmd_smoke() -> Result<()> { - println!("GrokBoy smoke (offline P5)…"); + println!("GrokBoy smoke (offline P6)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; @@ -416,11 +418,11 @@ async fn cmd_smoke() -> Result<()> { } println!(" completion ok"); - // tool definitions present (6 core + 6 browser incl. handoff) + // tool definitions present (6 core + confirm + 6 browser incl. handoff) let defs = tool_definitions(); let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0); - if n_tools != 12 { - return Err(anyhow!("expected 12 tool defs, got {n_tools}")); + if n_tools != 13 { + return Err(anyhow!("expected 13 tool defs, got {n_tools}")); } let tool_names: Vec<&str> = defs .as_array() @@ -431,6 +433,9 @@ async fn cmd_smoke() -> Result<()> { if !tool_names.contains(&"browser_handoff") { return Err(anyhow!("browser_handoff missing from tool defs")); } + if !tool_names.contains(&"request_user_confirm") { + return Err(anyhow!("request_user_confirm missing from tool defs")); + } println!(" tool defs ok"); // browser helper protocol (no Chromium required) @@ -489,6 +494,46 @@ async fn cmd_smoke() -> Result<()> { None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") }, } + // request_user_confirm (auto approve/deny; no interactive stdin) + let prev_confirm = std::env::var("GROKBOY_CONFIRM_AUTO").ok(); + unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "1") }; + let conf = execute_tool( + &ctx, + "request_user_confirm", + &json!({ + "reason": "smoke confirm check", + "prompt": "範例草稿", + "timeout_secs": 2 + }) + .to_string(), + ) + .await; + let conf_v: serde_json::Value = serde_json::from_str(&conf)?; + if conf_v["status"] != "approved" || conf_v["approved"] != true { + return Err(anyhow!("request_user_confirm approve mismatch: {conf_v}")); + } + println!(" confirm auto ok"); + unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort") }; + let deny = execute_tool( + &ctx, + "request_user_confirm", + &json!({"reason": "smoke deny check", "timeout_secs": 2}).to_string(), + ) + .await; + let deny_v: serde_json::Value = serde_json::from_str(&deny)?; + if deny_v["status"] != "denied" || deny_v["approved"] != false { + return Err(anyhow!("request_user_confirm deny mismatch: {deny_v}")); + } + let deny_wait = grokboy_core::wait_for_user_confirm(1); + if !matches!(deny_wait, grokboy_core::ConfirmWait::Denied(_)) { + return Err(anyhow!("expected ConfirmWait::Denied, got {deny_wait:?}")); + } + println!(" confirm deny ok"); + match prev_confirm { + Some(v) => unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", v) }, + None => unsafe { std::env::remove_var("GROKBOY_CONFIRM_AUTO") }, + } + // context truncation let mut msgs = vec![ ChatMessage::system(AGENT_SYSTEM), diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 0555c03..9dc1cf4 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -48,3 +48,15 @@ - [x] Offline smoke/tests: agent parses / help lists it; multi-turn session plumbing without API - [x] `cargo test` green without API key + +## P6 — scenario playbooks + confirm-before-post +- [x] Reusable pattern docs: `docs/scenarios/README.md`, `docs/SCENARIO-TEMPLATE.md` +- [x] Template prompts: `prompts/templates/phase-a.txt`, `phase-b.txt` (placeholders) +- [x] Example (not sole path): Shopee→Threads under `docs/scenarios/examples/` + `prompts/examples/` +- [x] Tool `request_user_confirm` (`reason`, optional `prompt`, optional `timeout_secs`) +- [x] Bilingual banner; yes/y/Enter approve; no/abort deny; timeout = deny (fail-closed) +- [x] Env: `GROKBOY_CONFIRM_AUTO` (fallback `GROKBOY_HANDOFF_AUTO`) +- [x] AGENT_SYSTEM: never irreversible public social publish without explicit approval this turn or confirm approved; prefer draft → confirm → act; handoff for auth only +- [x] Wired in tools.rs / confirm.rs; offline unit + smoke +- [x] PRODUCT.md / README pointer to scenario playbooks +- [x] `cargo test` / `grokboy smoke` green without API key diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 7c2f819..5464b97 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -2,7 +2,7 @@ **North star:** a local **Grok Bot–like** agent — thin CLI core, tool-using ReAct loop, optional Playwright **DOM** browser (not screenshot-first). GrokBoy is the sole main line; LazyBoy is reference only (no fork). -## Done (P0–P5) +## Done (P0–P6) | Phase | What | |-------|------| @@ -12,8 +12,9 @@ | **P3** | Optional Playwright DOM tools: navigate / snapshot / click / type / eval (fail-closed) | | **P4** | Human browser handoff (`browser_handoff`) for login / OTP / captcha | | **P5** | Interactive multi-turn agent REPL (`grokboy agent`) with tools + session persist | +| **P6** | Scenario playbooks + `request_user_confirm` (confirm-before-post) | -## P5 — Interactive multi-turn agent (this slice) +## P5 — Interactive multi-turn agent Gap after P4: `chat` streams but has no tools; `run` has tools but is one-shot. P5 adds **`grokboy agent`**: @@ -40,6 +41,32 @@ Gap after P4: `chat` streams but has no tools; `run` has tools but is one-shot. See `docs/ACCEPTANCE.md` section P5. +## P6 — Scenario playbooks + confirm-before-post + +**Capability:** reusable **scenario playbooks** for Grok Bot–style acceptance — not a single vertical hardcode. + +Pattern: + +1. **Phase A** — research + draft on a source portal (**no** irreversible publish) +2. Human only for: auth `browser_handoff` + final confirm +3. **Phase B** — publish to target channel **only** after explicit approval + +**Product primitive:** `request_user_confirm` (bilingual stdin gate; `GROKBOY_CONFIRM_AUTO` for tests, falls back to `GROKBOY_HANDOFF_AUTO`). AGENT_SYSTEM forbids irreversible public social posts without (a) explicit user approval this turn of the exact draft, or (b) confirm → approved. Prefer draft → confirm → act; prefer `browser_handoff` only for auth walls. + +**Docs:** [`docs/scenarios/README.md`](scenarios/README.md) (可推廣 pattern), [`docs/SCENARIO-TEMPLATE.md`](SCENARIO-TEMPLATE.md), `prompts/templates/`. + +**Example only (範例,非唯一路徑):** Shopee Affiliate → Threads — [`docs/scenarios/examples/shopee-threads-affiliate.md`](scenarios/examples/shopee-threads-affiliate.md) + `prompts/examples/shopee-threads-phase-*.txt`. + +### Env (P6) + +- `GROKBOY_CONFIRM_AUTO=1` — auto-approve confirm (tests); `abort` to deny +- Falls back to `GROKBOY_HANDOFF_AUTO` when CONFIRM_AUTO unset +- `GROKBOY_BROWSER_HEADED=1` — required for real cookie/auth scenarios + +### Acceptance (summary) + +See `docs/ACCEPTANCE.md` section P6. + ## Roadmap hint (later) -P6+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser. +P7+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance. diff --git a/docs/SCENARIO-TEMPLATE.md b/docs/SCENARIO-TEMPLATE.md new file mode 100644 index 0000000..6e5d7ef --- /dev/null +++ b/docs/SCENARIO-TEMPLATE.md @@ -0,0 +1,37 @@ +# Scenario template(複製此檔開新情境) + +> 填完後放到 `docs/scenarios/examples/.md`,並從 `prompts/templates/` 產出 phase-a/b。 + +## 後設資料 + +- **Slug**: `{{SLUG}}` +- **標題**: `{{TITLE}}` +- **SOURCE_PORTAL**: `{{SOURCE_PORTAL}}` +- **TARGET_CHANNEL**: `{{TARGET_CHANNEL}}` +- **GOAL**: `{{GOAL}}` +- **CONSTRAINTS**: `{{CONSTRAINTS}}` +- **LANGUAGE**: `{{LANGUAGE}}`(預設:繁體中文) + +## 成功標準(過/不過) + +- 過:… +- 不過:未確認發佈、多餘人工、空轉無 blocked 說明 + +## 允許的人工觸點 + +1. 來源登入 `browser_handoff` +2. 目標登入 `browser_handoff` +3. 最終 `request_user_confirm`(或聊天明確核准同一草稿) + +## Phase A / B + +見 `docs/scenarios/README.md`。本情境 Phase A prompt:`prompts/examples/{{SLUG}}-phase-a.txt`;Phase B:`…-phase-b.txt`。 + +## 執行 + +```bash +export GROKBOY_API_KEY=… +export GROKBOY_BROWSER_HEADED=1 +cargo run -p grokboy -- agent +# 貼上 phase-a → 審稿 → 貼 phase-b 或口頭核准 +``` diff --git a/docs/scenarios/README.md b/docs/scenarios/README.md new file mode 100644 index 0000000..f94bb9e --- /dev/null +++ b/docs/scenarios/README.md @@ -0,0 +1,107 @@ +# Scenario playbooks(情境劇本)— 可推廣的 GrokBoy 驗收模式 + +本目錄描述 **可重複套用** 的 acceptance playbook:把「研究來源 → 草稿 → 人工確認 → 發佈到目標頻道」做成固定兩階段流程。 +**蝦皮聯盟 → Threads** 只是一個填好的範例,不是唯一路徑。 + +產品原語(讓所有情境可推廣): + +| 原語 | 用途 | +|------|------| +| `browser_handoff` | 僅用於登入/OTP/驗證碼牆 | +| `request_user_confirm` | 任何不可逆公開動作(發文、送出)前必須過關 | +| Phase A / Phase B | 研究+草稿 vs 確認後才發佈 | + +相關:[`docs/SCENARIO-TEMPLATE.md`](../SCENARIO-TEMPLATE.md)、[`prompts/templates/`](../../prompts/templates/)、範例 [`examples/shopee-threads-affiliate.md`](examples/shopee-threads-affiliate.md)。 + +--- + +## 目標與成功標準 + +### 過(Pass) + +1. **端到端完成**:從來源入口找到可用素材/連結,產出目標頻道草稿,經確認後成功發佈(或明確 blocked)。 +2. **人工觸點最小化**:理想上只有 + (1) 來源站登入 handoff + (2) 目標頻道登入 handoff + (3) 最終發佈 confirm + — 其餘自動化。 +3. **零靜默發佈**:沒有「本輪明確核准的草稿」或 `request_user_confirm` → `approved`,就不得 publish/send。 +4. **Phase 分離**:Phase A **禁止**發佈;Phase B **只**在核准後發佈。 + +### 不過(Fail) + +- 未確認就發文/按送出 +- 非 headed 導致 cookie 遺失、無限重登 +- 列表/空結果空轉(loop guard 觸發仍算流程失敗若未 `report_blocked` 說明) +- 把登入以外的事丟給人(例如叫人手動複製貼上整篇文,而非 agent 貼上後只等 confirm) + +--- + +## Phase A — 研究 + 草稿(禁止發佈) + +1. 若需登入來源:`browser_handoff`(僅 auth)。 +2. 在 `{{SOURCE_PORTAL}}` 依 `{{GOAL}}` 找出約 2–5 個候選(高佣/相關/可分享)。 +3. (可選)快速看 `{{TARGET_CHANNEL}}` 語氣/近期風格。 +4. 用繁中(或指定語言)起草 **1** 則貼文,含連結;結構化輸出候選 + 草稿。 +5. `report_done` — **禁止** click send/publish。 + +## Phase B — 僅在確認後發佈 + +1. 僅在本 session 已有「明確核准的同一草稿」,或先呼叫 `request_user_confirm`(`prompt` = 全文草稿)。 +2. 若需登入目標頻道:`browser_handoff`。 +3. 貼上並發佈已核准草稿。 +4. `report_done`(盡量附貼文 URL);若未核准 → `report_blocked`,**不得**單方面發文。 + +--- + +## 環境與指令 + +```bash +export GROKBOY_API_KEY=你的金鑰 +export GROKBOY_BROWSER_HEADED=1 # 強烈建議;否則 cookie/session 易丟 +cd ~/GrokBoy +cargo run -p grokboy -- agent +``` + +測試用:`GROKBOY_CONFIRM_AUTO=1`(核准)/`abort`(拒絕);亦可沿用 `GROKBOY_HANDOFF_AUTO`。 + +### 如何貼 prompts + +1. 複製 `prompts/templates/phase-a.txt`,替換 `{{SOURCE_PORTAL}}`、`{{TARGET_CHANNEL}}`、`{{GOAL}}`、`{{CONSTRAINTS}}`。 +2. 貼進 `grokboy agent` 當本輪 user 訊息。 +3. 審草稿後,再貼 Phase B(或直接在聊天寫「核准,請發佈以下草稿:…」)。 + +填好的範例見 `prompts/examples/`。 + +--- + +## 如何實例化新情境 + +填寫模板欄位即可,例如: + +| 欄位 | 意義 | 範例 | +|------|------|------| +| `SOURCE_PORTAL` | 研究/取連結的站 | affiliate.shopee.tw、某官網活動頁 | +| `TARGET_CHANNEL` | 發佈目標 | Threads、FB、IG、X | +| `GOAL` | 成功長相 | 高佣商品約 3 個、季節主題 | +| `CONSTRAINTS` | 額外限制 | 語氣、禁用詞、長度、必須含 #tag | +| `LANGUAGE` | 草稿語言 | 繁體中文 | + +步驟:複製 template → 填 placeholder → 存成 `docs/scenarios/examples/.md` + `prompts/examples/-phase-{a,b}.txt` → 用 `agent` 跑驗收。 + +--- + +## 失敗模式速查 + +| 現象 | 處置 | +|------|------| +| Headless cookie 遺失 | `GROKBOY_BROWSER_HEADED=1` | +| 列表空轉 | `report_blocked`;換篩選/關鍵字;勿重複同工具 ×3 | +| 未確認就發 | **產品失敗**;AGENT_SYSTEM + `request_user_confirm` 擋 | +| 登入牆 | 只 handoff auth,完成後 Enter | + +--- + +## 範例索引 + +- [蝦皮聯盟 → Threads(範例)](examples/shopee-threads-affiliate.md) diff --git a/docs/scenarios/examples/shopee-threads-affiliate.md b/docs/scenarios/examples/shopee-threads-affiliate.md new file mode 100644 index 0000000..ca3c9cb --- /dev/null +++ b/docs/scenarios/examples/shopee-threads-affiliate.md @@ -0,0 +1,44 @@ +# 範例:蝦皮聯盟行銷 → Threads + +> **這是範例(example)**,用來示範如何把 [`docs/scenarios/README.md`](../README.md) 的可推廣模式套到真實站點。 +> 換掉來源/目標即可變成別的情境;不要把蝦皮當成唯一產品路徑。 + +| 欄位 | 值 | +|------|-----| +| Slug | `shopee-threads-affiliate` | +| SOURCE_PORTAL | https://affiliate.shopee.tw/ | +| TARGET_CHANNEL | Threads(threads.net) | +| GOAL | 找出約 3 個高佣金、適合公開分享的商品/連結 | +| CONSTRAINTS | 繁中短貼文;自然口吻;含聯盟連結;不誇大療效/投資 | +| LANGUAGE | 繁體中文 | + +## 成功標準 + +### 過 + +- Phase A:登入(handoff)→ 找到 ~3 候選 + 1 則 Threads 草稿(含連結)→ `report_done`,**未發佈** +- Phase B:本輪已核准同一草稿(聊天或 `request_user_confirm`)→ 登入 Threads(handoff)→ 發佈 → `report_done`(盡量含 URL) +- 人工僅:蝦皮登入、Threads 登入、最終 confirm + +### 不過 + +- 未確認就發 Threads +- 未 headed 導致重登/cookie 遺失 +- 聯盟列表空轉 +- 叫人手動貼全文(agent 應自己 type,只留 confirm) + +## 執行 + +```bash +export GROKBOY_API_KEY=你的金鑰 +export GROKBOY_BROWSER_HEADED=1 +cd ~/GrokBoy +cargo run -p grokboy -- agent +``` + +貼上: + +1. [`prompts/examples/shopee-threads-phase-a.txt`](../../../prompts/examples/shopee-threads-phase-a.txt) +2. 審草稿後貼 [`prompts/examples/shopee-threads-phase-b.txt`](../../../prompts/examples/shopee-threads-phase-b.txt),或回覆「核准,請發佈以下草稿:…」 + +由 template 填出:`prompts/templates/phase-a.txt` / `phase-b.txt`。 diff --git a/prompts/examples/shopee-threads-phase-a.txt b/prompts/examples/shopee-threads-phase-a.txt new file mode 100644 index 0000000..b64106e --- /dev/null +++ b/prompts/examples/shopee-threads-phase-a.txt @@ -0,0 +1,24 @@ +【Phase A — 研究 + 草稿;禁止發佈/送出】 +(範例:蝦皮聯盟 → Threads;由 prompts/templates/phase-a.txt 填入) + +來源入口 SOURCE_PORTAL: https://affiliate.shopee.tw/ +目標頻道 TARGET_CHANNEL: Threads(https://www.threads.net/) +目標 GOAL: 找出約 3 個高佣金、適合公開分享的商品/推廣連結 +限制 CONSTRAINTS: 繁中短貼文、自然口吻、含聯盟連結、不誇大療效或投資報酬、可加少量 hashtag +語言: 繁體中文 + +請執行 Phase A(research + draft only): + +1. 若 affiliate.shopee.tw 需要登入/OTP/驗證碼,呼叫 browser_handoff(僅 auth)。完成後從 snapshot 繼續。 +2. 在蝦皮聯盟找出約 3 個高佣、適合 Threads 分享的商品/連結(標題、佣金若可見、為何合適、完整推廣 URL)。 +3. (可選)快速看 Threads 動態了解語氣;不要發文。 +4. 用繁體中文起草剛好 1 則 Threads 貼文草稿,內含選中的聯盟連結;遵守 CONSTRAINTS。 +5. 輸出結構化摘要:candidates[]、chosen、draft_text、notes。 +6. 呼叫 report_done,訊息內含候選與完整草稿。 + +【禁止】 +- 不要在 Threads 按發佈/Post。 +- 不要對「發佈」類按鈕 browser_click。 +- browser_handoff 只用在登入牆。 + +結束前必須 report_done(或真的卡住才 report_blocked)。 diff --git a/prompts/examples/shopee-threads-phase-b.txt b/prompts/examples/shopee-threads-phase-b.txt new file mode 100644 index 0000000..6f1ddc1 --- /dev/null +++ b/prompts/examples/shopee-threads-phase-b.txt @@ -0,0 +1,24 @@ +【Phase B — 僅在核准後發佈】 +(範例:蝦皮聯盟 → Threads;由 prompts/templates/phase-b.txt 填入) + +目標頻道 TARGET_CHANNEL: Threads(https://www.threads.net/) +來源(參考)SOURCE_PORTAL: https://affiliate.shopee.tw/ + +前提:你必須已在本 session 看到使用者明確核准「同一則」草稿,或先 request_user_confirm。 + +已核准草稿(若使用者已貼在上方聊天則以此為準;否則請使用者貼上後再說「核准」): +--- +{{APPROVED_DRAFT}} +--- + +請執行 Phase B: + +1. 若本 session **沒有**清楚的核准訊息,先呼叫 request_user_confirm: + - reason: 即將發佈蝦皮聯盟推廣文到 Threads + - prompt: 完整草稿全文 + - 若 denied/timeout → report_blocked,禁止發佈。 +2. 打開 Threads;若需登入,browser_handoff(僅 auth)。 +3. 將已核准草稿貼上並發佈(僅此草稿)。 +4. report_done,盡量附貼文 URL。 + +【禁止】未核准不得發佈;不得擅自改稿後偷偷發;handoff 僅 auth。 diff --git a/prompts/templates/phase-a.txt b/prompts/templates/phase-a.txt new file mode 100644 index 0000000..18e6b54 --- /dev/null +++ b/prompts/templates/phase-a.txt @@ -0,0 +1,23 @@ +【Phase A — 研究 + 草稿;禁止發佈/送出】 + +來源入口 SOURCE_PORTAL: {{SOURCE_PORTAL}} +目標頻道 TARGET_CHANNEL: {{TARGET_CHANNEL}} +目標 GOAL: {{GOAL}} +限制 CONSTRAINTS: {{CONSTRAINTS}} +語言: {{LANGUAGE}} + +請執行 Phase A(research + draft only): + +1. 若 SOURCE_PORTAL 需要登入/OTP/驗證碼,呼叫 browser_handoff(僅 auth)。完成後從 snapshot 繼續。 +2. 在來源找出約 2–5 個符合 GOAL 的高價值候選(連結、標題、為何合適、佣金/重點若可見)。 +3. (可選)快速瀏覽 TARGET_CHANNEL 了解語氣/情境,勿發文。 +4. 用指定語言起草 **剛好 1** 則可直接貼上的貼文草稿,內含選中的聯盟/分享連結;遵守 CONSTRAINTS。 +5. 輸出結構化摘要:candidates[]、chosen、draft_text、notes。 +6. 呼叫 report_done,訊息內含候選與完整草稿。 + +【禁止】 +- 不要在 TARGET_CHANNEL(或任何社群)按發佈/送出/分享。 +- 不要呼叫會造成不可逆公開動作的 click(例如「發佈」「Post」「分享」)。 +- browser_handoff 只用在登入牆,不要用來叫人幫你寫文或手動發文。 + +結束前必須 report_done(或真的卡住才 report_blocked)。 diff --git a/prompts/templates/phase-b.txt b/prompts/templates/phase-b.txt new file mode 100644 index 0000000..a715e7e --- /dev/null +++ b/prompts/templates/phase-b.txt @@ -0,0 +1,26 @@ +【Phase B — 僅在核准後發佈】 + +目標頻道 TARGET_CHANNEL: {{TARGET_CHANNEL}} +來源(參考)SOURCE_PORTAL: {{SOURCE_PORTAL}} + +前提:使用者已在本 session 明確核准「同一則」草稿(聊天訊息貼出全文並說核准/發佈),或你必須先 request_user_confirm。 + +已核准草稿(若使用者已貼在上方聊天則以此為準;否則填入): +--- +{{APPROVED_DRAFT}} +--- + +請執行 Phase B: + +1. 若本 session **沒有**清楚的核准訊息,先呼叫 request_user_confirm: + - reason: 即將發佈到 TARGET_CHANNEL + - prompt: 完整草稿全文 + - 若回傳 denied/timeout → report_blocked,**禁止**發佈。 +2. 打開 TARGET_CHANNEL;若需登入,browser_handoff(僅 auth)。 +3. 將已核准草稿貼上並發佈(僅此一次、僅此草稿)。 +4. report_done,盡量附上貼文 URL;若看不到 URL,說明發佈結果與畫面狀態。 + +【禁止】 +- 未核准(聊天或 request_user_confirm)不得 publish/send。 +- 不得擅自改寫已核准草稿後偷偷發佈(若需改寫,回到 Phase A 或重新 confirm)。 +- browser_handoff 僅用於登入牆。