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
+64 -40
View File
@@ -1,8 +1,9 @@
/**
* Tests for agent-initiated element targeting (the `generate` command):
* POST /agent-target held-open pairing with POST /agent-target-result,
* validation, the no-browser and timeout verdicts, and the live-generate CLI's
* local failure modes.
* Protocol tests for agent-initiated element targeting (the `generate`
* command), driven against the engine binary: POST /agent-target held-open
* pairing with POST /agent-target-result, validation, the roll call and its
* leases, the no-browser and timeout verdicts, and the live-generate verb's
* local failure modes. Skips cleanly without a binary (tests/lib/engine-bin.mjs).
*
* Run with: node --test tests/live-agent-target.test.mjs
*/
@@ -14,37 +15,66 @@ import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFile, execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { getLiveServerPath } from '../skill/scripts/lib/impeccable-paths.mjs';
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
import { ENGINE_MISSING_MESSAGE, engineEnv, findEngineBinary } from './lib/engine-bin.mjs';
// Resolve the repo from this file, not from cwd: the runner may be invoked
// from tests/ or anywhere else.
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs');
const GENERATE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-generate.mjs');
const ENGINE_BIN = findEngineBinary();
// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs);
// read it from the Rust source so the matrix below can never drift from what
// the live server accepts.
function readVisualActions() {
const rust = readFileSync(join(REPO_ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8');
const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs');
return [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]);
}
const VISUAL_ACTIONS = readVisualActions();
function liveServerPath(cwd) {
return join(cwd, '.impeccable/live/server.json');
}
/** Run the live-generate verb; the JSON verdict is on stdout on every exit code. */
function runGenerate(cwd, args) {
return execFileSync(ENGINE_BIN, ['live-generate', ...args], {
cwd,
encoding: 'utf-8',
env: engineEnv(ENGINE_BIN, {}),
});
}
function startServer(port, { cwd, env = {} } = {}) {
return new Promise((resolve, reject) => {
const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], {
const proc = spawn(ENGINE_BIN, ['live-server', '--port=' + port], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env },
env: engineEnv(ENGINE_BIN, { IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }),
});
let output = '';
proc.stdout.on('data', (d) => {
output += d.toString();
if (output.includes('running on')) {
try {
const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8'));
resolve({ proc, port: info.port, token: info.token, cwd });
} catch {
reject(new Error('Server started but PID file not readable'));
}
}
});
proc.stdout.on('data', (d) => { output += d.toString(); });
proc.stderr.on('data', (d) => { output += d.toString(); });
proc.on('error', reject);
setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000);
// The server writes server.json on listen; poll for it rather than
// parsing the banner, so a slow first start still resolves.
const deadline = Date.now() + 10_000;
const tick = () => {
try {
const info = JSON.parse(readFileSync(liveServerPath(cwd), 'utf-8'));
if (info.port && info.token) {
resolve({ proc, port: info.port, token: info.token, cwd });
return;
}
} catch { /* not yet */ }
if (Date.now() > deadline) {
reject(new Error('Server start timeout. Output: ' + output));
return;
}
setTimeout(tick, 50);
};
tick();
});
}
@@ -116,7 +146,7 @@ async function openSseClient(server, { clientId } = {}) {
};
}
describe('POST /agent-target', () => {
describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
let tmp;
let server;
@@ -606,9 +636,9 @@ describe('POST /agent-target', () => {
for (const action of VISUAL_ACTIONS) {
const cli = new Promise((resolve) => {
execFile(
process.execPath,
[GENERATE_SCRIPT, '--selector', 'h1', '--action', action, '--dry-run'],
{ cwd: tmp, encoding: 'utf-8' },
ENGINE_BIN,
['live-generate', '--selector', 'h1', '--action', action, '--dry-run'],
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
);
});
@@ -666,7 +696,7 @@ describe('POST /agent-target', () => {
});
});
describe('live-generate CLI --wait-for-browser', () => {
describe('live-generate CLI --wait-for-browser', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
let tmp;
let server;
@@ -687,10 +717,7 @@ describe('live-generate CLI --wait-for-browser', () => {
function runCli(cwd, args) {
try {
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
cwd,
encoding: 'utf-8',
});
const stdout = runGenerate(cwd, args);
return { code: 0, json: JSON.parse(stdout) };
} catch (err) {
return { code: err.status, json: JSON.parse(err.stdout) };
@@ -721,9 +748,9 @@ describe('live-generate CLI --wait-for-browser', () => {
// and the delayed connect would never happen.
const child = new Promise((resolve) => {
execFile(
process.execPath,
[GENERATE_SCRIPT, '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
{ cwd: tmp, encoding: 'utf-8' },
ENGINE_BIN,
['live-generate', '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'],
{ cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) },
(err, stdout) => resolve({ code: err ? err.code : 0, stdout }),
);
});
@@ -737,13 +764,10 @@ describe('live-generate CLI --wait-for-browser', () => {
});
});
describe('live-generate CLI local failure modes', () => {
describe('live-generate CLI local failure modes', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
function runCli(cwd, args) {
try {
const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], {
cwd,
encoding: 'utf-8',
});
const stdout = runGenerate(cwd, args);
return { code: 0, json: JSON.parse(stdout) };
} catch (err) {
return { code: err.status, json: JSON.parse(err.stdout) };
@@ -756,7 +780,7 @@ describe('live-generate CLI local failure modes', () => {
const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder']);
assert.equal(code, 1);
assert.equal(json.error, 'server_not_running');
assert.match(json._instructions, /live\.mjs/);
assert.match(json._instructions, / live\)/, 'names the boot verb');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
+5 -4
View File
@@ -885,10 +885,11 @@ for (const { name, fixture } of fixtures) {
if (scenario.prompt) {
// The configure bar rebuild once discarded the preset prompt, so
// pin the regression at the wire: the journaled generate event
// must carry the prompt the CLI was given.
const journalPath = join(appRoot, '.impeccable/live/sessions', `${res.sessionId}.jsonl`);
const journaled = readFileSync(journalPath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
const generateEvent = journaled.find((entry) => entry.type === 'generate')?.event;
// must carry the prompt the CLI was given. The engine journals a
// generate event when the agent leases it from /poll, so wait
// for the entry instead of reading the journal right away.
const [journaled] = await waitForJournalEvent(appRoot, res.sessionId, 'generate');
const generateEvent = journaled?.event ?? journaled;
assert.equal(
generateEvent?.freeformPrompt,
scenario.prompt,
+13 -2
View File
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { compileProviderBlocks } from '../scripts/lib/utils.js';
import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs';
const ROOT = process.cwd();
@@ -178,8 +177,20 @@ describe('live reference authoring contract', () => {
// value the picker offers but the reference never names is a request
// the agent cannot route.
const generateMd = readFileSync(join(ROOT, 'skill/reference/generate.md'), 'utf-8');
for (const action of VISUAL_ACTIONS) {
for (const action of readVisualActions()) {
assert.match(generateMd, new RegExp('`' + action + '`'), `generate.md must name \`${action}\``);
}
});
});
// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs);
// read it from the Rust source so the parity check needs no binary and can
// never drift from what the live server accepts.
function readVisualActions() {
const rust = readFileSync(join(ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8');
const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs');
const actions = [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]);
if (actions.length !== Number(block[1])) throw new Error('VISUAL_ACTIONS length mismatch');
return actions;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* `live-generate` (the `generate` command's agent-initiated targeting): the
* verdicts the verb decides locally, without a browser, plus the one the
* helper answers when no overlay is attached. Everything that needs an
* overlay (the roll call, leases, replay) is covered by
* tests/live-agent-target.test.mjs and crates/cli/tests/agent_target.rs.
*/
import { LIVE_FILES } from '../live-helpers.mjs';
const NORM = [
['localhost:\\d{4,5}', 'g', 'localhost:<PORT>'],
['"(port|serverPort)":(\\s*)\\d{4,5}', 'g', '"$1":$2<PORT>'],
['Stopped live server on port \\d+\\.', 'g', 'Stopped live server on port <PORT>.'],
];
export default [
{
id: 'live-generate-local-verdicts', workspace: 'live-html', files: [...LIVE_FILES],
// No helper is recorded in the staged workspace, so every step short
// of a valid request ends in the verb's own verdict, and the valid one
// ends in server_not_running.
steps: [
{ verb: 'live-generate', args: ['--help'] },
{ verb: 'live-generate', args: [] },
{ verb: 'live-generate', args: ['--selector'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bold'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--count', '9'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--count', 'three'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--index', '0'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--wait-for-browser', 'soon'] },
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder'] },
],
},
{
id: 'live-generate-no-browser-connected', workspace: 'live-html', files: [...LIVE_FILES], normalize: NORM,
// A running helper with no overlay attached answers at once instead of
// holding the request.
steps: [
{ verb: 'live-server', daemon: true, readyFile: '.impeccable/live/server.json', readyTimeoutMs: 15000 },
{ verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder', '--count', '2', '--prompt', 'warmer'] },
{ verb: 'live-server', args: ['stop'] },
],
},
];
@@ -0,0 +1,61 @@
{
"steps": [
{
"stdout": "Usage: impeccable live-generate --selector <css> [--text <snippet>] [--index <n>] [--action <name>] [--count <n>] [--prompt <text>] [--dry-run] [--wait-for-browser <ms>]\n\nFlags:\n --selector <css> required; resolved with document.querySelectorAll\n --text <snippet> optional; keeps only matches whose textContent contains it\n --index <n> optional; 1-based pick among the remaining matches\n --action <name> optional; one of the live action vocabulary (default: impeccable)\n --count <n> optional; variants to request, 1-8 (default: 3)\n --prompt <text> optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser <ms> optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\n",
"stderr": "",
"exit": 0,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"selector_required\",\n \"_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.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"missing_flag_value\",\n \"flag\": \"--selector\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_action\",\n \"action\": \"bold\",\n \"validActions\": [\n \"impeccable\",\n \"bolder\",\n \"quieter\",\n \"distill\",\n \"polish\",\n \"typeset\",\n \"colorize\",\n \"layout\",\n \"adapt\",\n \"animate\",\n \"delight\",\n \"overdrive\"\n ],\n \"_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.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"9\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"three\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_index\",\n \"index\": \"0\",\n \"_instructions\": \"Pass --index as a 1-based integer position among the matches.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"invalid_wait\",\n \"wait\": \"soon\",\n \"_instructions\": \"Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"server_not_running\",\n \"_instructions\": \"No live helper server is recorded for this project. Run the live boot first (<IMPECCABLE> live), open the app URL that serves a pageFiles entry, then rerun this command.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
}
],
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n"
}
}
@@ -0,0 +1,32 @@
{
"steps": [
{
"stdout": "",
"stderr": "",
"exit": null,
"signal": null,
"daemon": true
},
{
"stdout": "{\n \"ok\": false,\n \"error\": \"no_browser_connected\",\n \"_instructions\": \"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.\"\n}\n",
"stderr": "",
"exit": 1,
"signal": null
},
{
"stdout": "Stopped live server on port <PORT>.\n",
"stderr": "",
"exit": 0,
"signal": null
}
],
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n"
},
"daemon": [
{
"stdout": "\nImpeccable live server running on http://localhost:<PORT>\nToken: <UUID>\n\nScript: http://localhost:<PORT>/live.js\nInject: managed by impeccable live-inject; Astro source tags use is:inline automatically.\nStop: impeccable live-server stop\n",
"stderr": ""
}
]
}
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
"stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
"exit": 1,
"signal": null,
"files": {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
"stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
"exit": 1,
"signal": null,
"files": {}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
"stderr": "",
"exit": 1,
"signal": null,
+1 -1
View File
@@ -1,5 +1,5 @@
{
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n",
"stdout": "Usage: impeccable pin <pin|unpin> <command>\n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n",
"stderr": "",
"exit": 1,
"signal": null,
+10 -3
View File
@@ -31,6 +31,7 @@ import {
import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs';
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
import { assertCompleted } from '../skill-workflow/assertions.mjs';
import { findEngineBinary } from '../lib/engine-bin.mjs';
import {
PRODUCT_MD_SAMPLE,
PRODUCT_MD_SAMPLE_NO_REGISTER,
@@ -106,10 +107,16 @@ function loadedBefore(trace, first, second) {
*/
function stopLiveHelper(workspace) {
try {
const engineBin = findEngineBinary();
execFileSync(
process.execPath,
[path.join(workspace, '.claude/skills/impeccable/scripts/live-server.mjs'), 'stop'],
{ cwd: workspace, stdio: 'ignore', timeout: 10_000 },
path.join(workspace, '.claude/skills/impeccable/scripts/impeccable'),
['live-server', 'stop'],
{
cwd: workspace,
stdio: 'ignore',
timeout: 10_000,
env: { ...process.env, ...(engineBin ? { IMPECCABLE_BIN: engineBin } : {}) },
},
);
} catch { /* nothing was running */ }
}