diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 4bc8ed422..63b796820 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -124,8 +124,14 @@ struct Server { token: String, } +/// Server spawns are serialized: seventeen binaries starting at once on a +/// loaded machine have missed even a 30 s pid-file wait, while the tests +/// themselves still run in parallel once their server is up. +static START_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + impl Server { fn start(tag: &str) -> Server { + let _serialized = START_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("impeccable-agent-target-{}-{}", tag, std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); diff --git a/crates/live/src/dev_url.rs b/crates/live/src/dev_url.rs new file mode 100644 index 000000000..4e1408dfb --- /dev/null +++ b/crates/live/src/dev_url.rs @@ -0,0 +1,124 @@ +//! Find the dev server that is serving this app right now: the page that +//! carries our injected `live.js?token=` tag is ours, whatever port +//! it answers on. Saves the agent a terminal-reading detour before it can +//! open the page. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// Ports worth a knock when nothing narrows the search: Vite, Next, Astro, +/// SvelteKit, Nuxt, CRA, Angular, and the usual static servers. +const DEFAULT_PORTS: &[u16] = &[5173, 3000, 4321, 8080, 4173, 3001, 5174, 8000, 4200, 5000, 1234]; + +/// Candidate origins, in probe order. `IMPECCABLE_DEV_URL_CANDIDATES` +/// (comma-separated) replaces the default list, for tests and unusual hosts. +pub fn candidates(env_override: Option<&str>) -> Vec { + if let Some(list) = env_override { + return list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.trim_end_matches('/').to_string() + "/") + .collect(); + } + let mut out = Vec::new(); + for port in DEFAULT_PORTS { + out.push(format!("http://127.0.0.1:{}/", port)); + out.push(format!("http://localhost:{}/", port)); + } + out +} + +/// The first candidate whose document contains our tag, probed in parallel +/// with short timeouts so a full miss costs well under a second. +pub fn probe(candidates: &[String], token: &str) -> Option { + let needle = format!("live.js?token={}", token); + let hits: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = candidates + .iter() + .map(|url| { + let needle = needle.clone(); + scope.spawn(move || fetch_root(url).filter(|body| body.contains(&needle)).map(|_| url.clone())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap_or(None)).collect() + }); + hits.into_iter().flatten().next() +} + +/// A minimal HTTP/1.0 GET of `/`; returns the response body on any 2xx. +fn fetch_root(url: &str) -> Option { + let rest = url.strip_prefix("http://")?; + let host_port = rest.split('/').next()?; + let (host, port) = match host_port.rsplit_once(':') { + Some((h, p)) => (h, p.parse::().ok()?), + None => (host_port, 80), + }; + let addr = (host, port).to_socket_addrs().ok()?.next()?; + let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(300)).ok()?; + stream.set_read_timeout(Some(Duration::from_millis(1500))).ok()?; + stream.set_write_timeout(Some(Duration::from_millis(300))).ok()?; + stream + .write_all(format!("GET / HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n", host_port).as_bytes()) + .ok()?; + let mut raw = Vec::new(); + let mut buf = [0u8; 8192]; + while raw.len() < 512 * 1024 { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => raw.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&raw).into_owned(); + let status_ok = text + .lines() + .next() + .map(|l| l.split_whitespace().nth(1).map(|c| c.starts_with('2')).unwrap_or(false)) + .unwrap_or(false); + if !status_ok { + return None; + } + Some(text.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + fn serve_once(body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for _ in 0..2 { + if let Ok((mut s, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf); + let _ = s.write_all( + format!("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n{}", body).as_bytes(), + ); + } + } + }); + format!("http://127.0.0.1:{}/", port) + } + + #[test] + fn finds_the_origin_that_serves_our_tag() { + let ours = serve_once(""); + let theirs = serve_once(""); + let dead = "http://127.0.0.1:1/".to_string(); + let found = probe(&[dead, theirs.clone(), ours.clone()], "abc-123"); + assert_eq!(found.as_deref(), Some(ours.as_str())); + assert_eq!(probe(&[theirs], "abc-123"), None); + } + + #[test] + fn env_override_replaces_the_default_list() { + let c = candidates(Some("http://localhost:9999, http://127.0.0.1:7777/")); + assert_eq!(c, vec!["http://localhost:9999/".to_string(), "http://127.0.0.1:7777/".to_string()]); + assert!(candidates(None).iter().any(|u| u == "http://127.0.0.1:5173/")); + } +} diff --git a/crates/live/src/instructions.rs b/crates/live/src/instructions.rs index 699846693..358f33bb8 100644 --- a/crates/live/src/instructions.rs +++ b/crates/live/src/instructions.rs @@ -23,6 +23,49 @@ use serde_json::{Map, Value}; const PLAN_POINTER: &str = "Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets."; +/// The three dimensions an agent-initiated generate varies for each action: +/// one per variant, so the trio reads as the same brand at three angles. +fn action_axes(action: &str) -> &'static str { + match action { + "bolder" => "scale (bigger type and tighter hierarchy) / saturation (commit the accent color) / structure (a stronger composition)", + "quieter" => "color (pull the accent back) / ornament (fewer decorations) / spacing (more air, softer edges)", + "distill" => "visual noise / redundant content / nested structure, one class of excess removed per variant", + "polish" => "rhythm / hierarchy / micro-details", + "typeset" => "a different pairing AND scale ratio per variant, within the available faces", + "colorize" => "a different hue family per variant, with its own chroma and contrast strategy", + "layout" => "three different structural arrangements, not spacing tweaks", + "adapt" => "mobile-first / tablet / desktop-or-print", + "animate" => "cascade stagger / clip wipe / scale-and-focus", + "delight" => "micro-interaction / typographic surprise / illustrated accent", + "overdrive" => "a different convention broken per variant: scale / structure / motion", + _ => "hierarchy / color strategy / density", + } +} + +/// What the poll tells the handler of a generate the agent itself started +/// (`origin: "agent"`): the user asked for variants to choose from, fast. +fn fast_path_instructions(event: &Map) -> String { + let action = event + .get("action") + .and_then(Value::as_str) + .filter(|a| !a.is_empty()) + .unwrap_or("impeccable"); + let count = js_str(event.get("count")); + let prompt = event + .get("freeformPrompt") + .and_then(Value::as_str) + .filter(|p| !p.trim().is_empty()) + .map(|p| format!(" The user's prompt narrows every variant: \"{}\".", slice16(p, 200))) + .unwrap_or_default(); + format!( + "Fast path (the user asked for {count} \"{action}\" variants to choose from, and is watching): do not read live.md, craft-floor.md, PRODUCT.md, or DESIGN.md now; the boot already handed you any design context, and this event carries element.computedStyles, element.cssCustomProperties, and element.parentContext. Lock the identity in ONE sentence from those (real colors, faces, corners, borders, shadows), then write {count} variants that each amplify a DIFFERENT dimension for {action}: {axes}. Keep the copy verbatim; no new fonts or hues beyond what the page already uses unless the prompt asks. No parameter knobs (no data-impeccable-params) unless the prompt asks for something tunable. Floors: body text contrast 4.5:1 or better, no text under 12px, controls at least 40px tall, focus states kept.{prompt}", + count = count, + action = action, + axes = action_axes(action), + prompt = prompt + ) +} + fn reply_cmd(self_cmd: &str, id: &str, rest: &str) -> String { format!("{} --reply {} {}", poll_cmd(self_cmd), id, rest) } @@ -201,13 +244,19 @@ fn generate_instructions(event: &Map, self_cmd: &str) -> String { )); } let action = event.get("action").filter(|a| truthy(Some(a))); + let agent_initiated = event.get("origin").and_then(Value::as_str) == Some("agent"); + if agent_initiated { + steps.push(fast_path_instructions(event)); + } match action { + Some(_) if agent_initiated => {} Some(a) if a.as_str() != Some("impeccable") => steps.push(format!( "Action is \"{}\": read reference/{}.md before planning; its MUST params are non-negotiable. {}", js_str(Some(a)), js_str(Some(a)), PLAN_POINTER )), + _ if agent_initiated => {} _ => steps.push(format!( "Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. {}", PLAN_POINTER @@ -385,3 +434,35 @@ fn accept_instructions(event: &Map, self_cmd: &str) -> String { prefix, file ) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn generate_event(origin: Option<&str>) -> Map { + let mut m = Map::new(); + m.insert("type".into(), json!("generate")); + m.insert("id".into(), json!("ab12cd34")); + m.insert("action".into(), json!("bolder")); + m.insert("count".into(), json!(3)); + m.insert("element".into(), json!({ "tagName": "section", "id": "pricing", "classes": ["pricing"], "textContent": "Simple pricing" })); + if let Some(o) = origin { + m.insert("origin".into(), json!(o)); + } + m + } + + #[test] + fn an_agent_initiated_generate_gets_the_fast_path_not_the_planning_ceremony() { + let text = generate_instructions(&generate_event(Some("agent")), "impeccable"); + assert!(text.contains("Fast path"), "{text}"); + assert!(text.contains("scale (bigger type"), "{text}"); + assert!(text.contains("No parameter knobs"), "{text}"); + assert!(!text.contains("live.md section 4"), "{text}"); + assert!(!text.contains("read reference/bolder.md"), "{text}"); + let user = generate_instructions(&generate_event(None), "impeccable"); + assert!(user.contains("live.md section 4"), "{user}"); + assert!(!user.contains("Fast path"), "{user}"); + } +} diff --git a/crates/live/src/lib.rs b/crates/live/src/lib.rs index 0ee5f9407..ccf3ffea8 100644 --- a/crates/live/src/lib.rs +++ b/crates/live/src/lib.rs @@ -10,6 +10,7 @@ pub mod browser_assets; pub mod config; pub mod copy_edit_agent; pub mod design_md; +pub mod dev_url; pub mod event_validation; pub mod gitignore; pub mod inject; diff --git a/crates/live/src/live_boot.rs b/crates/live/src/live_boot.rs index f93ae8645..c7ddf1aca 100644 --- a/crates/live/src/live_boot.rs +++ b/crates/live/src/live_boot.rs @@ -118,7 +118,12 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if design.is_none() { missing.push("DESIGN.md"); } - if !missing.is_empty() { + // `--allow-missing-context`: a caller that would rather start from the + // page than from an interview (the generate command) boots anyway; the + // payload names what is missing so the agent extracts the identity + // from the surface instead of running init or document mid-session. + let allow_missing_context = args.iter().any(|a| a == "--allow-missing-context"); + if !missing.is_empty() && !allow_missing_context { let payload = json!({ "ok": false, "error": "context_missing", @@ -260,10 +265,35 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { break; } let self_cmd = impeccable_context::provider::detect(&env, &cwd).self_cmd; + // 6. Which dev server is serving this app right now (the page carrying + // our tag), so the agent opens it without reading terminals. + let token_for_probe = match server_info.get("token") { + Some(Value::String(s)) => s.clone(), + _ => String::new(), + }; + let dev_url = if token_for_probe.is_empty() { + None + } else { + crate::dev_url::probe( + &crate::dev_url::candidates(env.get("IMPECCABLE_DEV_URL_CANDIDATES").map(String::as_str)), + &token_for_probe, + ) + }; + let context_note = if missing.is_empty() { + Value::Null + } else { + json!(format!( + "Booted without {} (--allow-missing-context). Extract the identity from the picked element's computed styles, CSS custom properties, and sibling styling; do not run init or document during this session, and do not ask for them.", + missing.join(" and ") + )) + }; let payload = json!({ "ok": true, "serverPort": server_info.get("port").cloned().unwrap_or(Value::Null), "serverToken": server_info.get("token").cloned().unwrap_or(Value::Null), + "devUrl": dev_url, + "contextMissing": missing, + "contextNote": context_note, "pageFiles": resolved_files, "liveConfigPath": check_result.get("path").cloned().unwrap_or(Value::Null), "configDrift": drift, diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 6e9702db1..222e8a22d 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -120,7 +120,7 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. An accepted generate event carrying the envelope is journaled and queued with `origin: "agent"`, and `live-poll` renders that event's `_instructions` as the fast path (identity from the event's `element.computedStyles` / `cssCustomProperties` / `parentContext`, the action's three dimensions, no parameter knobs unless the prompt asks, one edit, reply done) instead of the interactive planning pointer and the action-reference read. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | @@ -1727,13 +1727,13 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit - Flow & outputs (all pretty-printed JSON, 2 spaces, exit 0 unless noted): 1. Workspace monorepo selection (`resolveTargetSelection`, only when no target, cwd is a workspace/monorepo root with discoverable children): `{ok:false, error:'target_selection_required', targetPath:null, projectRoot, repoRoot, targetCandidates:[{name, path, targetExample, …context summary}], hint:'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.'}`. 2. `resolveRoots` selection → `{ok:false, error:'target_selection_required', targetCandidates:[{name,path}], hint:'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target .'}`. - 3. Missing/unreadable/empty PRODUCT.md or DESIGN.md → `{ok:false, error:'context_missing', missing:['PRODUCT.md'?,'DESIGN.md'?], nextCommand:'init'|'document', targetPath, projectRoot, repoRoot, productPath:rel|null, designPath:rel|null}`. + 3. Missing/unreadable/empty PRODUCT.md or DESIGN.md → `{ok:false, error:'context_missing', missing:['PRODUCT.md'?,'DESIGN.md'?], nextCommand:'init'|'document', targetPath, projectRoot, repoRoot, productPath:rel|null, designPath:rel|null}`. With `--allow-missing-context` the boot continues instead: the success payload carries `contextMissing:[...]` (the same names; `[]` when nothing is missing) and `contextNote` (a sentence telling the agent to extract the identity from the page and never run init or document mid-session; `null` when nothing is missing), with `hasProduct`/`hasDesign` false and `product`/`design` null for the missing ones. 4. `writeRootsManifest(roots)`. 5. `node live-inject.mjs --check` (cwd appRoot, 15 s): not ok → print that JSON (`{ok:false,error:'config_missing'|'config_invalid',path,message?}` or `{ok:false,error:'check_failed',raw}`) + `targetPath, projectRoot, repoRoot`, exit 0. 6. Reuse server if `server.json` pid alive, else `node live-server.mjs --background`; failure → `{ok:false,error:'server_start_failed'}` exit 1. 7. `node live-inject.mjs --port P --token T`; not ok → `{ok:false,error:'inject_failed',detail:,serverPort}` exit 1. 8. Drift scan: `.html` files under `public, src, app, pages` (skipping ignored dirs/dot-dirs) not in resolved files and not user-excluded → `configDrift = {orphans:[≤20], orphanCount, hint:'N HTML file(s) exist but aren\'t in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".'}` else `null`. - 9. Success: `{ok:true, serverPort, serverToken, pageFiles:[…resolved], liveConfigPath, configDrift, targetPath, projectRoot:appRoot, repoRoot, roots:{manifest}, hasProduct:true, product:, productPath:rel, hasDesign:true, design:, designPath:rel, hasSurfaceBrief, surfaceBrief:, surfaceBriefPath:rel|null, _instructions:'Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run node /live-poll.mjs immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.'}`. Surface brief resolved from `.impeccable/surfaces` under appRoot, contextRoot, repoRoot (first hit). + 9. Success: `{ok:true, serverPort, serverToken, devUrl, contextMissing, contextNote, pageFiles:[…resolved], liveConfigPath, configDrift, targetPath, projectRoot:appRoot, repoRoot, roots:{manifest}, hasProduct:true, product:, productPath:rel, hasDesign:true, design:, designPath:rel, hasSurfaceBrief, surfaceBrief:, surfaceBriefPath:rel|null, _instructions:'Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run node /live-poll.mjs immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.'}`. Surface brief resolved from `.impeccable/surfaces` under appRoot, contextRoot, repoRoot (first hit). `devUrl` is the origin of the dev server serving this app right now, found by fetching `/` on the candidate origins (`http://127.0.0.1:

/` and `http://localhost:

/` for p in 5173, 3000, 4321, 8080, 4173, 3001, 5174, 8000, 4200, 5000, 1234, probed in parallel with sub-second timeouts; `IMPECCABLE_DEV_URL_CANDIDATES` replaces the list with a comma-separated one) and keeping the first whose document contains the injected `live.js?token=` tag; `null` when none does. - Tests: `tests/live-target-context.test.mjs`, `tests/live-roots.test.mjs`, `tests/live-e2e.test.mjs` (`session.liveBoot` for `appDir` fixtures), `tests/live-recovery-commands.test.mjs`. #### `live-server.mjs` -> `impeccable live-server` diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index b2246f8fe..95d371e27 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -141,6 +141,7 @@ export const SUITES = { /^tests\/live-e2e\//, /^tests\/lib\/engine-bin\.mjs$/, /^tests\/live-agent-target\.test\.mjs$/, + /^tests\/live-boot-fastpath\.test\.mjs$/, ], commands: [ { @@ -148,6 +149,7 @@ export const SUITES = { files: [ 'tests/live-reference.test.mjs', 'tests/live-agent-target.test.mjs', + 'tests/live-boot-fastpath.test.mjs', 'tests/live-browser-ignores.test.mjs', 'tests/live-browser-source.test.mjs', 'tests/live-e2e-agent-output.test.mjs', diff --git a/skill/reference/generate.md b/skill/reference/generate.md index 4746a108b..cc078272e 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -1,14 +1,17 @@ > **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page. -Generate is a programmatic entry into live mode: the user names an element, a direction, and a count in one sentence, and you boot the live session, point the browser at the element, and the overlay scrolls to it, selects it, and fires the same Go a user click fires. Everything downstream is the standard live session. Read [live.md](live.md) in full now if you have not this session; this file is the entry ramp into its contract, and from Step 4 on you are inside it, with one deliberate divergence: Step 5 closes the session on its own once the accept lands, instead of staying open the way `live` does. +Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. You boot the helper, open the page, and hand the element to `impeccable live-generate`; the overlay scrolls to it, selects it, and fires the same Go a click fires. This file is the whole contract for that lane. **Do not read [live.md](live.md) for it**: every tool output carries `_instructions` with the next move for that exact situation, and they win over anything you remember. Open live.md only for a situation this file names as outside the lane. **Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead. -Three prohibitions cover the known ways this command goes wrong. Each names the tempting move first: +Speed is the product here. Every tool call before the variants land is a second the user spends staring at a selected element. The lane below is five commands and one edit; anything beyond it needs a reason from the output in front of you. This lane also replaces Setup step 3 for the preview edit: the floors craft-floor.md guards are written into Step 4, so do not open craft-floor.md, and read the action's reference only when Step 4 says so. -- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit. -- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects. -- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag. +Four prohibitions cover the known ways this command goes wrong: + +- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the boot prints them and you use them. When they do not, the boot says so and you extract the identity from the page (Step 4). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends. +- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed in Step 2 or Step 3, never with a direct source edit. +- **Open the page yourself** (Step 2). A pasted link usually means no page ever connects. +- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. ## Step 1: Parse the request @@ -33,60 +36,93 @@ Three parts, all from the user's sentence: Done when you hold an action from the vocabulary, a count from 1 to 8, and the element description. -## Step 2: Boot live mode and open the page +## Step 2: Boot and open the page -Run the boot exactly as [live.md](live.md)'s Start section describes: +One command. Pass `--target` with the file that renders the element when the request or the project makes it obvious; skip it otherwise. Always pass `--allow-missing-context`: it lets the boot proceed when PRODUCT.md or DESIGN.md is absent and changes nothing when both exist. ```bash -{{scripts_path}}/impeccable live +{{scripts_path}}/impeccable live --target src/App.jsx --allow-missing-context ``` -**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first. +Read three fields of the output and nothing else: -Then open the app URL that serves a `pageFiles` entry (never `serverPort`; that is the helper, not the app): +- `product` / `design` (or `contextMissing` with a `contextNote`): the design context you have. Present means use it; missing means the page is the source of truth, per the note. Either way, continue. +- `devUrl`: the dev server that is serving this app right now. **Open it**: Cursor `browser_navigate`, any other harness its browser tool. `devUrl: null` means no dev server is serving the page yet: start the project's dev script in a background terminal (`npm run dev` or the framework's equivalent), open the URL it prints, and never kill or restart it afterwards. +- `pageFiles`: the page the helper injected into; the URL that serves it is the one to open (never `serverPort`, that is the helper). -- **Cursor**: `browser_navigate` to the URL now; do not skip it. -- **Any other harness with a browser tool**: open the URL with that tool. -- **No browser tool exists in this harness**: tell the user the exact URL to open, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects. +**No browser tool in this harness**: tell the user the exact URL in one line, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects. -Done when the boot printed `"ok": true` and a page with the overlay is connected, which Step 3 proves by answering anything other than `no_browser_connected`. +**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first, then rerun the boot. + +Done when the boot printed `"ok": true` and a page is open. You do not need to read `package.json`, the dev-server config, terminal logs, or the page source to get here. ## Step 3: Target the element -Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session. +One command. Derive the selector from what the user said and what you already know of the project: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain. ```bash -{{scripts_path}}/impeccable live-generate --selector "section.pricing" --action bolder --count 3 +{{scripts_path}}/impeccable live-generate --selector "#pricing" --action bolder --count 3 ``` Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `. -Every verdict carries `_instructions` with the next move for that exact situation, with real values filled in; follow them over your recollection of this file. Two verdicts deserve naming because their fix sits outside the command: +Every verdict carries `_instructions`; follow them over your recollection of this file. Two deserve naming: - **`no_browser_connected`**: Step 2's page is not actually open; open it yourself, then rerun. -- **`ambiguous`**: the candidates are listed in the output; target their common container, or rerun with `--text ""` or `--index `. +- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text ""` or `--index `. -Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, entered the picked state, and fired Go. +Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, selected it, and fired Go. ## Step 4: Generate -Start the poll loop per your harness policy in [live.md](live.md). The queued event for the returned `sessionId` is a standard `generate` event with the picked element's context and a preflighted scaffold; handle it exactly per live.md's Handle generate, which owns everything from planning to the done reply. +Start the poll. Harness policy: **Cursor** runs `{{scripts_path}}/impeccable live-poll` one-shot in a background terminal with notify on `"type":"(generate|accept|discard|variant_mount_failed|exit)"`, handles the event, replies, and restarts the poll; **Claude Code** runs it as a background task; **Codex** runs it one-shot in a yielded foreground exec session and services it; never pass a short `--timeout=`. -Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."* +The first event is the `generate` for your `sessionId`, and its `_instructions` are the whole plan: the fast path names the identity sources (the event's `element.computedStyles`, `cssCustomProperties`, and `parentContext`, plus whatever the boot printed), the three dimensions your variants vary for this action, the no-knobs default, and the exact splice. Do it in ONE edit and reply done. Concretely: -**Publishing variants does not end the session.** Keep servicing the poll; accept, discard, and carbonize cleanup follow live.md unchanged, and the helper server stays up through the accept. Done when live.md's contract marks the event you handled complete and the poll is running again. +1. **Identity, one sentence, from the event.** Real colors, faces, corners, borders, shadows, and the layout topology on screen. DESIGN.md wins when the boot printed one. Never read PRODUCT.md, DESIGN.md, live.md, or craft-floor.md for this; never screenshot the page. +2. **The action's reference is optional.** Read `reference/.md` only when the prompt or the element makes the direction unclear; the `_instructions` already carry the action's three dimensions. +3. **Write the splice.** The event's `scaffold` tells you where: `sourceWritten: false` hands you `wrapperBlock` and the source range to replace (`replaceStartLine` to `replaceEndLine`); a written wrapper hands you `file` and `insertLine`. Either way, one edit lands the preview CSS plus all variants: -## Step 5: Close the session +```html + + +

+
+
+``` -Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `impeccable live-complete` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup: + Rules that keep the browser mounting what you wrote: each variant div holds exactly ONE top-level element, same tag as the original, with the copy verbatim; first variant visible, the rest `display: none`; every `:scope` rule steps into a descendant (`:scope > .card`, never a bare `:scope`); use the `styleTag` and selector strategy from the event's `cssAuthoring` when it differs from the sketch above. **JSX / TSX**: wrap the `