From f29636a3d5da158d28451cf5510137830ca9e20f Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 08:16:05 +0500 Subject: [PATCH] Keep the generate lane's boot extras behind flags The speed pass had every `impeccable live` boot probe for the dev server and print three new keys (devUrl, contextMissing, contextNote), which moved an oracle golden and cost a plain live session a probe it never asked for. The lane's extras are opt-in now: `--dev-url` runs the probe and reports devUrl; `--allow-missing-context` reports the context keys. Without either flag the boot's work and payload are byte-identical to before, which the restored golden and a new boot test pin. generate.md passes both flags; the contract doc says so. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/live/src/live_boot.rs | 24 +++++++++++++------ docs/CLI-CONTRACT.md | 4 ++-- skill/reference/generate.md | 4 ++-- tests/live-boot-fastpath.test.mjs | 14 ++++++++--- tests/oracle/golden/live-boot-full-cycle.json | 2 +- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/live/src/live_boot.rs b/crates/live/src/live_boot.rs index c7ddf1aca..69b06ec1a 100644 --- a/crates/live/src/live_boot.rs +++ b/crates/live/src/live_boot.rs @@ -265,13 +265,17 @@ 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. + // The generate lane's two opt-ins. Both are silent unless asked for, so + // a plain boot's payload is unchanged: `--dev-url` probes which dev + // server is serving this app right now (the page carrying our tag) and + // reports `devUrl`; `--allow-missing-context` reports `contextMissing` + // and a `contextNote` for the files it let the boot proceed without. + let want_dev_url = args.iter().any(|a| a == "--dev-url"); 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() { + let dev_url = if !want_dev_url || token_for_probe.is_empty() { None } else { crate::dev_url::probe( @@ -287,13 +291,10 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { missing.join(" and ") )) }; - let payload = json!({ + let mut 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, @@ -312,6 +313,15 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { "surfaceBriefPath": surface_brief_path, "_instructions": boot_instructions(&self_cmd), }); + if let Some(obj) = payload.as_object_mut() { + if want_dev_url { + obj.insert("devUrl".into(), dev_url.map(Value::String).unwrap_or(Value::Null)); + } + if allow_missing_context { + obj.insert("contextMissing".into(), json!(missing)); + obj.insert("contextNote".into(), context_note); + } + } println(io, &json_pretty(&payload)); 0 } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 09a24fa2a..869e42b24 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -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}`. 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. + 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, and only then the success payload gains `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, 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. + 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). With `--dev-url` (and only then) the payload also carries `devUrl`, 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. Without either flag the payload and the boot's work are exactly as before: no probe runs and neither key appears. - 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/skill/reference/generate.md b/skill/reference/generate.md index cc078272e..50dc860a7 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -38,10 +38,10 @@ Done when you hold an action from the vocabulary, a count from 1 to 8, and the e ## Step 2: Boot and open the page -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. +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 both flags: `--allow-missing-context` lets the boot proceed when PRODUCT.md or DESIGN.md is absent (it changes nothing when both exist), and `--dev-url` asks the boot to find the dev server. Neither touches a plain `live` session. ```bash -{{scripts_path}}/impeccable live --target src/App.jsx --allow-missing-context +{{scripts_path}}/impeccable live --target src/App.jsx --allow-missing-context --dev-url ``` Read three fields of the output and nothing else: diff --git a/tests/live-boot-fastpath.test.mjs b/tests/live-boot-fastpath.test.mjs index d97d4a6e9..9dcaa1ec7 100644 --- a/tests/live-boot-fastpath.test.mjs +++ b/tests/live-boot-fastpath.test.mjs @@ -60,7 +60,7 @@ describe('live boot fast lane', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESS assert.equal(refused.error, 'context_missing'); assert.deepEqual(refused.missing, ['PRODUCT.md', 'DESIGN.md']); - const booted = await run(tmp, ['live', '--allow-missing-context'], { IMPECCABLE_DEV_URL_CANDIDATES: `http://127.0.0.1:1/, ${devUrl}` }); + const booted = await run(tmp, ['live', '--allow-missing-context', '--dev-url'], { IMPECCABLE_DEV_URL_CANDIDATES: `http://127.0.0.1:1/, ${devUrl}` }); assert.equal(booted.ok, true, JSON.stringify(booted)); assert.deepEqual(booted.contextMissing, ['PRODUCT.md', 'DESIGN.md']); assert.match(booted.contextNote, /do not run init or document/); @@ -73,14 +73,22 @@ describe('live boot fast lane', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESS assert.ok(stopped.ok !== false, JSON.stringify(stopped)); }); - it('reports devUrl null when nothing serves the injected page, and no contextMissing when both files exist', async () => { + it('with both files present the flags report devUrl null and an empty contextMissing', async () => { writeFileSync(join(tmp, 'PRODUCT.md'), '# Product\n\n## Platform\n\nweb\n'); writeFileSync(join(tmp, 'DESIGN.md'), '---\nname: Test\n---\n# Design\n'); - const booted = await run(tmp, ['live'], { IMPECCABLE_DEV_URL_CANDIDATES: 'http://127.0.0.1:1/' }); + const booted = await run(tmp, ['live', '--allow-missing-context', '--dev-url'], { IMPECCABLE_DEV_URL_CANDIDATES: 'http://127.0.0.1:1/' }); assert.equal(booted.ok, true, JSON.stringify(booted)); assert.deepEqual(booted.contextMissing, []); assert.equal(booted.contextNote, null); assert.equal(booted.devUrl, null); await run(tmp, ['live-server', 'stop']); }); + + it('a plain live boot is untouched by the lane: no probe, no new keys', async () => { + const booted = await run(tmp, ['live'], { IMPECCABLE_DEV_URL_CANDIDATES: devUrl }); + assert.equal(booted.ok, true, JSON.stringify(booted)); + assert.ok(!('devUrl' in booted), 'devUrl only appears with --dev-url'); + assert.ok(!('contextMissing' in booted) && !('contextNote' in booted), 'context keys only appear with --allow-missing-context'); + await run(tmp, ['live-server', 'stop']); + }); }); diff --git a/tests/oracle/golden/live-boot-full-cycle.json b/tests/oracle/golden/live-boot-full-cycle.json index 0d9447419..35c03c975 100644 --- a/tests/oracle/golden/live-boot-full-cycle.json +++ b/tests/oracle/golden/live-boot-full-cycle.json @@ -1,7 +1,7 @@ { "steps": [ { - "stdout": "{\n \"ok\": true,\n \"serverPort\": ,\n \"serverToken\": \"\",\n \"devUrl\": null,\n \"contextMissing\": [],\n \"contextNote\": null,\n \"pageFiles\": [\n \"index.html\",\n \"public/no-body.html\",\n \"public/docs/guide.html\"\n ],\n \"liveConfigPath\": \"/.impeccable/live/config.json\",\n \"configDrift\": {\n \"orphans\": [\n \"src/cards.html\"\n ],\n \"orphanCount\": 1,\n \"hint\": \"1 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \\\"public/**/*.html\\\".\"\n },\n \"targetPath\": null,\n \"projectRoot\": \"\",\n \"repoRoot\": \"\",\n \"roots\": {\n \"version\": 1,\n \"appRoot\": \"\",\n \"repoRoot\": \"\",\n \"contextRoot\": \"\",\n \"sessionRoot\": \"/.impeccable/live\",\n \"productPath\": \"/PRODUCT.md\",\n \"designPath\": \"/DESIGN.md\",\n \"resolvedFrom\": \"cwd\"\n },\n \"hasProduct\": true,\n \"product\": \"\\n# Product\\n\\n## What it is\\nA fixture app used by the oracle harness for live mode.\\n\\n## Platform\\nweb\\n\",\n \"productPath\": \"PRODUCT.md\",\n \"hasDesign\": true,\n \"design\": \"---\\nname: Oracle Live Fixture\\ndescription: A one-page fixture set in plain type on paper-white surfaces.\\ncolors:\\n ink: \\\"#142720\\\"\\n paper: \\\"#ffffff\\\"\\ntypography:\\n body:\\n fontFamily: \\\"system-ui, sans-serif\\\"\\n fontWeight: 400\\n lineHeight: 1.5\\n---\\n\",\n \"designPath\": \"DESIGN.md\",\n \"hasSurfaceBrief\": false,\n \"surfaceBrief\": null,\n \"surfaceBriefPath\": null,\n \"_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 live-poll 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.\"\n}\n", + "stdout": "{\n \"ok\": true,\n \"serverPort\": ,\n \"serverToken\": \"\",\n \"pageFiles\": [\n \"index.html\",\n \"public/no-body.html\",\n \"public/docs/guide.html\"\n ],\n \"liveConfigPath\": \"/.impeccable/live/config.json\",\n \"configDrift\": {\n \"orphans\": [\n \"src/cards.html\"\n ],\n \"orphanCount\": 1,\n \"hint\": \"1 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \\\"public/**/*.html\\\".\"\n },\n \"targetPath\": null,\n \"projectRoot\": \"\",\n \"repoRoot\": \"\",\n \"roots\": {\n \"version\": 1,\n \"appRoot\": \"\",\n \"repoRoot\": \"\",\n \"contextRoot\": \"\",\n \"sessionRoot\": \"/.impeccable/live\",\n \"productPath\": \"/PRODUCT.md\",\n \"designPath\": \"/DESIGN.md\",\n \"resolvedFrom\": \"cwd\"\n },\n \"hasProduct\": true,\n \"product\": \"\\n# Product\\n\\n## What it is\\nA fixture app used by the oracle harness for live mode.\\n\\n## Platform\\nweb\\n\",\n \"productPath\": \"PRODUCT.md\",\n \"hasDesign\": true,\n \"design\": \"---\\nname: Oracle Live Fixture\\ndescription: A one-page fixture set in plain type on paper-white surfaces.\\ncolors:\\n ink: \\\"#142720\\\"\\n paper: \\\"#ffffff\\\"\\ntypography:\\n body:\\n fontFamily: \\\"system-ui, sans-serif\\\"\\n fontWeight: 400\\n lineHeight: 1.5\\n---\\n\",\n \"designPath\": \"DESIGN.md\",\n \"hasSurfaceBrief\": false,\n \"surfaceBrief\": null,\n \"surfaceBriefPath\": null,\n \"_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 live-poll 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.\"\n}\n", "stderr": "", "exit": 0, "signal": null