Port /impeccable generate to the engine crates

The Node-era server, CLI, hook, and pin halves of the generate command move
into the Rust workspace, with the protocol unchanged:

- crates/live: POST /agent-target is held open on a channel plus a timer
  thread (the manual-apply deferred pattern), releasing its turnstile
  ticket before it parks like /poll; /agent-target-result resolves it;
  /agent-target-claim is the roll call with its renewable lease. SSE
  connections carry the overlay's clientId: a late overlay is replayed
  every pending target, and a disconnect retires that overlay's report,
  releases its lease, and re-judges each roll call. Shutdown drains held
  requests with server_stopping.
- crates/live/src/live_generate.rs: the live-generate verb (the router
  already forwards every live* verb), same flags, verdicts, and
  _instructions, spelled with the engine's self command.
- crates/hook: every entry stands down on live preview markers
  (skipped: live-preview), checking the proposed content and the file on
  disk for hook-before-edit.
- crates/context: pin accepts generate; the crate's command-metadata.json
  copy carries its entry.

Tests: crates/cli/tests/agent_target.rs (six HTTP cases with an SSE reader),
tests/live-agent-target.test.mjs rewritten to drive the binary (28 cases,
registered in the live suite), hook stand-down cases, oracle goldens for
live-generate plus the re-recorded pin list goldens, the e2e prompt
assertion waiting for the journaled event, and the contract documented in
docs/CLI-CONTRACT.md.

AI-assisted: implemented and tested with Claude Code under maintainer
direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-15 05:45:49 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent fc89b0ed62
commit d397140a77
26 changed files with 1533 additions and 302 deletions
+5 -5
View File
@@ -8,7 +8,7 @@ Three prohibitions cover the known ways this command goes wrong. Each names the
- 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; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag.
- 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.
## Step 1: Parse the request
@@ -38,7 +38,7 @@ Done when you hold an action from the vocabulary, a count from 1 to 8, and the e
Run the boot exactly as [live.md](live.md)'s Start section describes:
```bash
node {{scripts_path}}/live.mjs
{{scripts_path}}/impeccable live
```
**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first.
@@ -56,7 +56,7 @@ Done when the boot printed `"ok": true` and a page with the overlay is connected
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.
```bash
node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3
{{scripts_path}}/impeccable live-generate --selector "section.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 <ms>`.
@@ -78,10 +78,10 @@ Then tell the user, in one line, where their variants are: *"Three [bolder] vari
## Step 5: Close the session
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 `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup:
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:
```bash
node {{scripts_path}}/live-server.mjs stop
{{scripts_path}}/impeccable live-server stop
```
Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server.
-227
View File
@@ -1,227 +0,0 @@
#!/usr/bin/env node
/**
* Agent-initiated element targeting for the `generate` command.
*
* Asks the live overlay to find an element by CSS selector, scroll to it,
* enter the picked state, and fire the normal Go pipeline with the given
* action and count. On success the browser starts a standard generate
* session; the agent then handles the resulting `generate` event from the
* poll loop exactly as live.md describes. Requires a running live helper
* server (live.mjs boot) and an open page with the overlay attached.
*
* Usage:
* node <scripts_path>/live-generate.mjs --selector "section.pricing" --action bolder --count 3
* node <scripts_path>/live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer"
*
* Flags:
* --selector <css> required; resolved with document.querySelectorAll
* --text <snippet> optional; keeps only matches whose textContent contains it
* --index <n> optional; 1-based pick among the remaining matches
* --action <name> optional; one of the live action vocabulary (default: impeccable)
* --count <n> optional; variants to request, 1-8 (default: 3)
* --prompt <text> optional; freeform direction, same as typing before Go
* --dry-run optional; resolve and report without starting anything
* --wait-for-browser <ms> optional; poll the helper until a page with the
* overlay connects (or the budget runs out) before
* sending the target. For harnesses with no browser
* tool: hand the user the URL, run with this flag, and
* the command fires as soon as they open the page.
*/
import process from 'node:process';
import { enterLiveRoot } from './live/roots.mjs';
import { VISUAL_ACTIONS } from './live/vocabulary.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
enterLiveRoot(process.cwd());
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful print (nodejs/node#56645, issue #573), matching context.mjs.
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
async function fail(payload) {
console.log(JSON.stringify(payload, null, 2));
await destroyFetchDispatcher();
process.exit(1);
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const key = arg.slice(2);
if (key === 'dry-run') { args['dry-run'] = true; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith('--')) {
// Pre-fetch validation inside a sync helper: no socket can exist yet,
// so a plain synchronous exit is safe here.
console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2));
process.exit(1);
}
args[key] = value;
i += 1;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const selector = (args.selector || '').trim();
if (!selector) {
await fail({
ok: false,
error: 'selector_required',
_instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "<visible text>" when the class repeats.',
});
}
const action = args.action || 'impeccable';
if (!VISUAL_ACTIONS.includes(action)) {
await fail({
ok: false,
error: 'invalid_action',
action,
validActions: VISUAL_ACTIONS,
_instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.',
});
}
const count = args.count === undefined ? 3 : Number(args.count);
if (!Number.isInteger(count) || count < 1 || count > 8) {
await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' });
}
let index;
if (args.index !== undefined) {
index = Number(args.index);
if (!Number.isInteger(index) || index < 1) {
await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' });
}
}
let waitForBrowserMs = 0;
if (args['wait-for-browser'] !== undefined) {
waitForBrowserMs = Number(args['wait-for-browser']);
if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) {
await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' });
}
}
const found = readLiveServerInfo(process.cwd());
if (!found || !found.info || !found.info.port || !found.info.token) {
await fail({
ok: false,
error: 'server_not_running',
_instructions: 'No live helper server is recorded for this project. Run the live boot first (node <scripts_path>/live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.',
});
}
const { port, token } = found.info;
const INSTRUCTIONS = {
ok: (r) => (r.dryRun
? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.`
: `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`),
no_browser_connected: () => 'No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.',
browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.',
invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.',
no_match: (r) => (r.rawMatchCount > 0
? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.`
: 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'),
ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "<visible text>" or --index <1-based position>. The candidates are listed in this output.`,
index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`,
busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`,
go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`,
server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.',
};
async function waitForBrowserConnection(budgetMs) {
const deadline = Date.now() + budgetMs;
for (;;) {
let status;
try {
const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, {
signal: AbortSignal.timeout(5_000),
});
status = await res.json();
} catch (err) {
await fail({
ok: false,
error: 'server_unreachable',
detail: err?.message,
_instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
});
}
if ((status.connectedClients || 0) > 0) return;
if (Date.now() >= deadline) {
await fail({
ok: false,
error: 'no_browser_connected',
waitedMs: budgetMs,
_instructions: INSTRUCTIONS.no_browser_connected(),
});
}
await new Promise((r) => setTimeout(r, 1_000));
}
}
async function main() {
if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs);
const body = {
token,
selector,
action,
count,
...(args.text ? { text: args.text } : {}),
...(index !== undefined ? { index } : {}),
...(args.prompt ? { prompt: args.prompt } : {}),
...(args['dry-run'] ? { dryRun: true } : {}),
};
let res;
try {
res = await fetch(`http://127.0.0.1:${port}/agent-target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
// Client-side cap just above the server's 15s hold, so a hung helper
// still fails fast.
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError';
await fail({
ok: false,
error: timedOut ? 'request_timeout' : 'server_unreachable',
detail: err?.message,
_instructions: timedOut
? INSTRUCTIONS.browser_timeout()
: 'The recorded live server did not answer; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
});
}
let result;
try {
result = await res.json();
} catch {
await fail({ ok: false, error: 'bad_server_response', status: res.status });
}
if (!res.ok) {
await fail({ ok: false, error: result.error || `http_${res.status}`, ...result });
}
const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error];
const output = {
...result,
...(instructions ? { _instructions: instructions(result) } : {}),
};
console.log(JSON.stringify(output, null, 2));
await destroyFetchDispatcher();
process.exit(result.ok ? 0 : 1);
}
main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message }));