diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs
index 1affdda43..ddf1f0d99 100644
--- a/cli/engine/engines/regex/detect-text.mjs
+++ b/cli/engine/engines/regex/detect-text.mjs
@@ -345,12 +345,66 @@ const REGEX_ANALYZERS = [
];
// ---------------------------------------------------------------------------
-// Style block extraction (Vue/Svelte ', start);
+ if (end < 0) return '';
+ return source.slice(start, end)
+ .replace(/^\s*\{\s*`\s*/, '')
+ .replace(/\s*`\s*\}\s*$/, '')
+ .trim();
+}
+
+function atomicReplace(target, content) {
+ let mode = 0o666;
+ try { mode = fs.statSync(target).mode; } catch {}
+ const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
+ try {
+ fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
+ fs.renameSync(temp, target);
+ } finally {
+ try { fs.unlinkSync(temp); } catch {}
+ }
+}
+
+function resolveInside(cwd, value) {
+ const resolved = path.resolve(cwd, value);
+ const rel = path.relative(cwd, resolved);
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
+ return resolved;
+}
+
+function relative(cwd, value) {
+ return path.relative(cwd, value).split(path.sep).join('/');
+}
+
+function failure(error, details = {}) {
+ return { ok: false, error, ...details };
+}
diff --git a/skill/scripts/live/poll-lanes.mjs b/skill/scripts/live/poll-lanes.mjs
new file mode 100644
index 000000000..65f20a87a
--- /dev/null
+++ b/skill/scripts/live/poll-lanes.mjs
@@ -0,0 +1,14 @@
+export function eventPriority(event = {}) {
+ if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
+ if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
+ if (event.type === 'generate') return 2;
+ return 3;
+}
+
+export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
+ const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
+ return entries
+ .filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
+ .filter((entry) => !allowed || allowed.has(entry.event?.type))
+ .sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
+}
diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs
index affba67c9..52e41d722 100644
--- a/skill/scripts/live/session-store.mjs
+++ b/skill/scripts/live/session-store.mjs
@@ -3,6 +3,13 @@ import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+const GENERATION_FENCED_PHASES = new Set([
+ 'accept_requested',
+ 'discard_requested',
+ 'carbonize_required',
+ 'completed',
+ 'discarded',
+]);
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
@@ -38,7 +45,10 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {})
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
}
- const prior = loadCachedOrRebuild(normalized.id);
+ // Publisher/complete helpers can append from a separate process while
+ // the server is alive. Rebuild here so sequence numbers and phase
+ // fences never come from a stale in-memory cache.
+ const prior = rebuildSnapshotFromJournal(getReadableJournalPath(normalized.id), normalized.id);
const seq = prior.nextSeq;
const entry = {
seq,
@@ -116,9 +126,21 @@ function baseSnapshot(id) {
pendingEvent: null,
deliveryLease: null,
checkpointRevision: 0,
+ browserCheckpointRevision: 0,
+ publicationCheckpointRevision: 0,
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
+ generationPhase: null,
+ generationTimings: {},
+ generationEpoch: 1,
+ publishedRevision: 0,
+ deliveredVariants: {},
+ variantPlan: null,
+ paramsPublished: false,
+ generationCanceled: false,
+ generationCanceledAt: null,
+ cancelReason: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
@@ -158,6 +180,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ generationTimings: { ...(snapshot.generationTimings || {}) },
+ deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
+ variantPlan: snapshot.variantPlan || null,
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
@@ -170,14 +195,81 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
+ next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
+ next.variantPlan = null;
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
+ case 'variant_plan':
+ if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
+ next.variantPlan = event.plan ?? next.variantPlan;
+ }
+ break;
+ case 'detector_waivers':
+ if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
+ next.detectorWaivers = [
+ ...(next.detectorWaivers || []),
+ ...(Array.isArray(event.waivers) ? event.waivers : []),
+ ];
+ }
+ break;
+ case 'variant_published':
+ if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
+ next.diagnostics.push({
+ error: 'late_generation_event_ignored',
+ type: event.type,
+ phase: next.phase,
+ revision: event.revision ?? null,
+ });
+ break;
+ }
+ if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
+ next.diagnostics.push({
+ error: 'stale_generation_epoch_ignored',
+ epoch: event.generationEpoch ?? null,
+ expectedEpoch: next.generationEpoch || 1,
+ });
+ break;
+ }
+ next.phase = 'variants_progress';
+ next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
+ next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
+ next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
+ if (event.publicationKind === 'params') next.paramsPublished = true;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.revision) {
+ next.deliveredVariants[String(event.revision)] = {
+ digest: event.digest || null,
+ arrivedVariants: Number(event.arrivedVariants || 0),
+ publishedAt: event.at || null,
+ };
+ }
+ break;
+ case 'agent_phase':
+ next.generationPhase = event.phase ?? next.generationPhase;
+ if (event.phase) {
+ next.generationTimings[event.phase] = {
+ at: event.at ?? (Date.parse(entry.ts || '') || null),
+ durationMs: event.durationMs ?? null,
+ };
+ }
+ break;
case 'variants_ready':
case 'agent_done':
+ if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
+ && !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
+ next.diagnostics.push({
+ error: 'late_generation_event_ignored',
+ type: event.type,
+ phase: next.phase,
+ });
+ break;
+ }
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
@@ -194,27 +286,45 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
}
break;
case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
+ if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ {
+ const revisionDomain = event.revisionDomain === 'publication'
+ || (event.reason === 'variants_progress' && !event.owner)
+ ? 'publication'
+ : 'browser';
+ const revisionField = revisionDomain === 'publication'
+ ? 'publicationCheckpointRevision'
+ : 'browserCheckpointRevision';
+ const currentRevision = next[revisionField]
+ ?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
+ ?? 0;
+ if ((event.revision ?? 0) >= currentRevision) {
+ next.phase = event.phase ?? next.phase;
+ next[revisionField] = event.revision ?? currentRevision;
+ if (revisionDomain === 'browser') {
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ }
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
+ }
}
break;
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
+ next.generationCanceled = true;
+ next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
+ next.cancelReason = 'accept';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
@@ -232,6 +342,12 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
+ case 'carbonize_cleanup':
+ next.phase = 'carbonize_cleanup_requested';
+ next.sourceFile = event.file ?? next.sourceFile;
+ next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
+ next.pendingEvent = toPendingEvent(event);
+ break;
case 'steer_done':
next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
@@ -243,6 +359,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'discard':
next.phase = 'discard_requested';
+ next.generationCanceled = true;
+ next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
+ next.cancelReason = 'discard';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
@@ -260,6 +379,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
next.pendingEvent = null;
break;
case 'agent_error':
+ if (next.generationCanceled && event.sourceEventType === 'generate') {
+ next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
+ break;
+ }
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
diff --git a/skill/scripts/live/source-artifact.mjs b/skill/scripts/live/source-artifact.mjs
new file mode 100644
index 000000000..53f9bff3b
--- /dev/null
+++ b/skill/scripts/live/source-artifact.mjs
@@ -0,0 +1,76 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
+
+export const SOURCE_ARTIFACT_PREVIEW_MODE = 'source-artifact';
+
+export function scaffoldSourceArtifactSession({
+ id,
+ count,
+ sourceFile,
+ sourceStartLine,
+ sourceEndLine,
+ originalSource,
+ previewContent,
+ cwd = process.cwd(),
+} = {}) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) {
+ throw new Error('invalid source artifact session id');
+ }
+ const sourcePath = resolveInside(cwd, sourceFile);
+ if (!sourcePath || !fs.existsSync(sourcePath)) throw new Error('source artifact target missing');
+
+ const sessionDir = path.join(getLiveDir(cwd), 'previews', id);
+ const extension = path.extname(sourcePath) || '.html';
+ const previewPath = path.join(sessionDir, 'preview' + extension);
+ const manifestPath = path.join(sessionDir, 'manifest.json');
+ fs.mkdirSync(sessionDir, { recursive: true });
+
+ const manifest = {
+ id,
+ count: Number(count || 1),
+ previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
+ sourceFile: relative(cwd, sourcePath),
+ previewFile: relative(cwd, previewPath),
+ sourceStartLine: Number(sourceStartLine),
+ sourceEndLine: Number(sourceEndLine),
+ originalSource: String(originalSource || ''),
+ };
+ fs.writeFileSync(previewPath, String(previewContent || ''), 'utf-8');
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
+ return { ...manifest, manifestFile: relative(cwd, manifestPath), sessionDir: relative(cwd, sessionDir) };
+}
+
+export function findSourceArtifactManifest(id, cwd = process.cwd()) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) return null;
+ const manifestPath = path.join(getLiveDir(cwd), 'previews', id, 'manifest.json');
+ let manifest;
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { return null; }
+ if (manifest?.id !== id || manifest?.previewMode !== SOURCE_ARTIFACT_PREVIEW_MODE) return null;
+ const sourcePath = resolveInside(cwd, manifest.sourceFile);
+ const previewPath = resolveInside(cwd, manifest.previewFile);
+ if (!sourcePath || !previewPath || !fs.existsSync(sourcePath) || !fs.existsSync(previewPath)) return null;
+ return { ...manifest, manifestPath, sourcePath, previewPath };
+}
+
+export function removeSourceArtifactSession(id, cwd = process.cwd()) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(id || ''))) return false;
+ const sessionDir = path.join(getLiveDir(cwd), 'previews', id);
+ if (!fs.existsSync(sessionDir)) return false;
+ fs.rmSync(sessionDir, { recursive: true, force: true });
+ return true;
+}
+
+function resolveInside(cwd, value) {
+ if (!value || typeof value !== 'string') return null;
+ const root = path.resolve(cwd);
+ const resolved = path.resolve(root, value);
+ const rel = path.relative(root, resolved);
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
+ return resolved;
+}
+
+function relative(cwd, value) {
+ return path.relative(cwd, value).split(path.sep).join('/');
+}
diff --git a/skill/scripts/live/source-lock.mjs b/skill/scripts/live/source-lock.mjs
new file mode 100644
index 000000000..dd82989bd
--- /dev/null
+++ b/skill/scripts/live/source-lock.mjs
@@ -0,0 +1,56 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
+
+const STALE_LOCK_MS = 60_000;
+
+export function sourceLockPath(file, cwd = process.cwd()) {
+ const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
+ return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
+}
+
+export function withSourceLockSync(file, owner, fn, {
+ cwd = process.cwd(),
+ waitMs = 0,
+ retryMs = 5,
+} = {}) {
+ const lockPath = sourceLockPath(file, cwd);
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
+ const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
+ let fd;
+ while (fd === undefined) {
+ clearStaleLock(lockPath);
+ try {
+ fd = fs.openSync(lockPath, 'wx');
+ fs.writeFileSync(fd, JSON.stringify({ owner, pid: process.pid, at: Date.now(), file: path.resolve(cwd, file) }) + '\n');
+ } catch (error) {
+ if (error?.code !== 'EEXIST') throw error;
+ if (Date.now() >= deadline) {
+ const locked = new Error('source_locked');
+ locked.code = 'SOURCE_LOCKED';
+ locked.lockPath = lockPath;
+ throw locked;
+ }
+ sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
+ }
+ }
+
+ try {
+ return fn();
+ } finally {
+ try { if (fd !== undefined) fs.closeSync(fd); } catch {}
+ try { fs.unlinkSync(lockPath); } catch {}
+ }
+}
+
+function sleepSync(ms) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+function clearStaleLock(lockPath) {
+ try {
+ const stat = fs.statSync(lockPath);
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.unlinkSync(lockPath);
+ } catch {}
+}
diff --git a/skill/scripts/live/vue-component.mjs b/skill/scripts/live/vue-component.mjs
new file mode 100644
index 000000000..c8f4d0825
--- /dev/null
+++ b/skill/scripts/live/vue-component.mjs
@@ -0,0 +1,343 @@
+/**
+ * Nuxt/Vue live-mode component previews.
+ *
+ * Generation writes real Vue SFCs into a generated app-local module tree.
+ * Nuxt/Vite compiles those modules without touching the active route; Accept
+ * is the only operation that writes the user's .vue source.
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
+
+export function detectNuxtVueProject(cwd = process.cwd()) {
+ const configFile = fs.readdirSync(cwd, { withFileTypes: true })
+ .find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
+ if (!configFile) return null;
+ const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
+ const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
+ let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
+ if (srcDirMatch) {
+ const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
+ if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
+ appDir = candidate === '.' ? '' : candidate;
+ }
+ }
+ const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
+ return { configFile, appDir, componentRoot };
+}
+
+export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
+ if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
+ return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
+}
+
+export function vueComponentSessionDir(id, cwd = process.cwd()) {
+ const project = detectNuxtVueProject(cwd);
+ if (!project) throw new Error('Nuxt project not found');
+ return path.join(cwd, project.componentRoot, id);
+}
+
+export function vueManifestPathForSession(id, cwd = process.cwd()) {
+ return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
+}
+
+function ensureVueRuntime(cwd = process.cwd()) {
+ const project = detectNuxtVueProject(cwd);
+ if (!project) throw new Error('Nuxt project not found');
+ const rel = `${project.componentRoot}/__runtime.js`;
+ const file = path.join(cwd, rel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
+ if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
+ return nuxtViteFsModulePath(file, cwd);
+}
+
+/**
+ * Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
+ * Keep the manifest path base-agnostic and let the browser prepend the
+ * runtime's actual buildAssetsDir. A page-route URL such as
+ * `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
+ */
+export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
+ const absolute = path.resolve(cwd, file).split(path.sep).join('/');
+ const relative = path.relative(cwd, absolute);
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
+ throw new Error('Nuxt live module must stay inside the project root');
+ }
+ return '/@fs/' + absolute.replace(/^\/+/, '');
+}
+
+export function extractVueExpressions(markup) {
+ const out = [];
+ const seen = new Set();
+ const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
+ let match;
+ while ((match = re.exec(String(markup || '')))) {
+ const expr = match[1].trim();
+ if (!expr || seen.has(expr)) continue;
+ seen.add(expr);
+ out.push({ expr, token: match[0] });
+ }
+ return out;
+}
+
+function buildVuePropContract(expressions) {
+ return expressions.map(({ expr, token }, index) => ({
+ prop: derivePropName(expr, index),
+ expr,
+ placeholder: token,
+ // DOMParser sees Vue interpolation `{{ user.name }}` as text containing
+ // the inner `{ user.name }` token; preserve its whitespace for the
+ // browser's source-text → rendered-text map.
+ previewToken: token.slice(1, -1),
+ }));
+}
+
+function derivePropName(expr, index) {
+ const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
+ return tail?.[1] || `prop${index}`;
+}
+
+function substituteVueExpressions(markup, contract) {
+ let out = String(markup || '');
+ for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
+ return out;
+}
+
+function buildVueVariantStub(variant, markup, contract) {
+ const props = contract.length > 0
+ ? `\n\n`
+ : '';
+ return `${props}\n${markup.trim()}\n\n\n\n`;
+}
+
+export function scaffoldVueComponentSession({
+ id,
+ count,
+ sourceFile,
+ sourceStartLine,
+ sourceEndLine,
+ originalLines,
+ cwd = process.cwd(),
+}) {
+ const runtimeModule = ensureVueRuntime(cwd);
+ const dir = vueComponentSessionDir(id, cwd);
+ fs.mkdirSync(dir, { recursive: true });
+ const originalMarkup = originalLines.join('\n');
+ const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
+ const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
+ const manifest = {
+ id,
+ previewMode: 'vue-component',
+ framework: 'vue',
+ componentExtension: 'vue',
+ sourceFile: sourceFile.split(path.sep).join('/'),
+ sourceStartLine,
+ sourceEndLine,
+ count,
+ propContract,
+ originalMarkup,
+ componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ componentModuleBase: nuxtViteFsModulePath(dir, cwd),
+ runtimeModule,
+ };
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
+ for (let variant = 1; variant <= count; variant++) {
+ const file = path.join(dir, `v${variant}.vue`);
+ if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
+ }
+ return {
+ manifest,
+ manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
+ componentDir: manifest.componentDir,
+ propContract,
+ };
+}
+
+export function findVueComponentManifest(id, cwd = process.cwd()) {
+ let direct;
+ try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
+ if (!fs.existsSync(direct)) return null;
+ try {
+ const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
+ return manifest?.id === id && manifest?.previewMode === 'vue-component'
+ ? { ...manifest, manifestPath: direct }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseVueSfc(source) {
+ const text = String(source || '');
+ const template = text.match(/]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
+ const style = text.match(/'];
+ return [...lines.slice(0, close), ...block, ...lines.slice(close)];
+}
+
+function mergeOriginalVueAttrs(markup, originalMarkup) {
+ const variant = matchOpeningTag(markup);
+ const original = matchOpeningTag(originalMarkup);
+ if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
+ const variantAttrs = parseStaticAttrs(variant.attrs);
+ const originalAttrs = parseStaticAttrs(original.attrs);
+ const additions = [];
+ let attrs = variant.attrs;
+
+ const originalClass = originalAttrs.get('class');
+ const variantClass = variantAttrs.get('class');
+ if (originalClass && variantClass) {
+ const classes = [
+ ...variantClass.value.split(/\s+/),
+ ...originalClass.value.split(/\s+/),
+ ].filter(Boolean);
+ const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
+ attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
+ } else if (originalClass) {
+ additions.push(originalClass.raw);
+ }
+ for (const [name, attr] of originalAttrs) {
+ if (name === 'class' || variantAttrs.has(name)) continue;
+ additions.push(attr.raw);
+ }
+ const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
+ return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
+}
+
+function matchOpeningTag(markup) {
+ const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
+ return match ? {
+ raw: match[0],
+ tag: match[1],
+ attrs: match[2] || '',
+ close: match[3],
+ index: match.index || 0,
+ } : null;
+}
+
+function parseStaticAttrs(attrs) {
+ const out = new Map();
+ const re = /([A-Za-z_:][\w:.-]*)\s*=\s*(["'])(.*?)\2/g;
+ let match;
+ while ((match = re.exec(attrs))) {
+ out.set(match[1], {
+ raw: match[0],
+ value: match[3],
+ quote: match[2],
+ start: match.index,
+ end: match.index + match[0].length,
+ });
+ }
+ return out;
+}
+
+export function removeVueComponentSession(id, cwd = process.cwd()) {
+ try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
+}
+
+/**
+ * Make an accepted/discarded session undiscoverable immediately while keeping
+ * Vue modules that Vite has in its graph alive until Live shuts down. Deleting
+ * an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
+ * missing module and emit a console error. The generated directory remains
+ * ignored and removeAllVueComponentSessions removes it on server shutdown.
+ */
+export function retireVueComponentSession(id, cwd = process.cwd()) {
+ let dir;
+ try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
+ for (const name of ['manifest.json', 'params.json']) {
+ try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
+ }
+}
+
+export function removeAllVueComponentSessions(cwd = process.cwd()) {
+ const project = detectNuxtVueProject(cwd);
+ if (!project) return;
+ const root = path.join(cwd, project.componentRoot);
+ if (!fs.existsSync(root)) return;
+ fs.rmSync(root, { recursive: true, force: true });
+}
+
+export function buildVueComponentCssAuthoring(count) {
+ return {
+ mode: 'vue-component',
+ count,
+ requirements: [
+ 'Write each variant as a real Vue SFC in componentDir/vN.vue.',
+ 'Keep one root element inside and put variant CSS in
diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs
index 7d3769298..45df1eaed 100644
--- a/tests/framework-fixtures.test.mjs
+++ b/tests/framework-fixtures.test.mjs
@@ -119,6 +119,9 @@ for (const name of listFixtures()) {
'.impeccable/live/server.json',
'.impeccable/live/sessions/example.jsonl',
'.impeccable/live/previews/example/v1.html',
+ '.impeccable/live/artifacts/example-r1.jsx',
+ '.impeccable/live/accept-receipts/example.json',
+ '.impeccable/live/locks/example.lock',
'.impeccable/live/deferred-svelte-component-accepts.json',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
@@ -127,6 +130,9 @@ for (const name of listFixtures()) {
assert.match(ignored, /\.impeccable\/live\/server\.json/);
assert.match(ignored, /\.impeccable\/live\/sessions\/example\.jsonl/);
assert.match(ignored, /\.impeccable\/live\/previews\/example\/v1\.html/);
+ assert.match(ignored, /\.impeccable\/live\/artifacts\/example-r1\.jsx/);
+ assert.match(ignored, /\.impeccable\/live\/accept-receipts\/example\.json/);
+ assert.match(ignored, /\.impeccable\/live\/locks\/example\.lock/);
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
assert.match(ignored, /src\/lib\/impeccable\/ImpeccableLiveRoot\.svelte/);
assert.match(ignored, /src\/lib\/impeccable\/__runtime\.js/);
@@ -142,6 +148,15 @@ for (const name of listFixtures()) {
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
return;
}
+ if (result.adapter === 'nuxt') {
+ const plugin = result.results[0];
+ const body = readFileSync(join(tmp, plugin.file), 'utf-8');
+ assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
+ assert.match(body, /impeccable-live-nuxt-plugin/);
+ assert.match(body, /if \(!import\.meta\.dev/);
+ assert.match(body, /localhost:9999\/live\.js/);
+ return;
+ }
for (const r of result.results) {
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
const body = readFileSync(join(tmp, r.file), 'utf-8');
@@ -169,6 +184,11 @@ for (const name of listFixtures()) {
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
return;
}
+ if (result.adapter === 'nuxt') {
+ assert.equal(result.results[0].removed, true);
+ assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
+ return;
+ }
for (const r of result.results) {
const body = readFileSync(join(tmp, r.file), 'utf-8');
assert.doesNotMatch(body, /impeccable-live-start/);
diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md
index ddac42a6e..fb0fc6a72 100644
--- a/tests/framework-fixtures/README.md
+++ b/tests/framework-fixtures/README.md
@@ -112,6 +112,7 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). |
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
+| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
@@ -119,3 +120,44 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `nuxt-csp/` | Nuxt `routeRules` with literal CSP header in `nuxt.config.ts`. CSP shape `append-string`. |
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
+
+## External quality-eval fixtures
+
+The public Live benchmark can execute a fixture owned by another repository
+without copying its task corpus or rubric into Impeccable:
+
+```sh
+bun run bench:live -- \
+ --fixture-dir=/absolute/path/to/private-fixture \
+ --agent=codex \
+ --action=bolder \
+ --iterations=1 \
+ --evidence-bundle=/absolute/path/to/output-bundle
+```
+
+An external fixture has the same shape as a directory in this folder:
+`fixture.json`, `gitignore.txt`, and `files/`. Use the optional
+`evidenceCapture` block in `fixture.json` for rubric-free capture metadata:
+
+```json
+{
+ "evidenceCapture": {
+ "captureSelector": "section.case-study",
+ "mode": "target",
+ "viewport": { "width": 1440, "height": 1080 },
+ "action": "bolder"
+ }
+}
+```
+
+Use `"mode": "target"` when `captureSelector` is the picked element itself;
+the original resolves through that selector and each variant resolves through
+its exact Live wrapper. Omit it when the selector is a stable ancestor used as
+shared page context for every capture.
+
+The bundle contains `report.json`, the original capture, each progressively
+delivered variant capture, geometry/overflow facts, hashes, and timing data.
+It deliberately cannot run `--judge-rendered`; comparative rubrics, private
+fixtures, human calibration, and quality decisions belong in the consuming
+evaluation harness. The normal public E2E suite remains responsible for Live
+protocol, framework, source-commit, cleanup, and recovery correctness.
diff --git a/tests/framework-fixtures/nuxt-vite7/files/app.vue b/tests/framework-fixtures/nuxt-vite7/files/app.vue
deleted file mode 100644
index 80fbb63be..000000000
--- a/tests/framework-fixtures/nuxt-vite7/files/app.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
- Nuxt + Vite 7 Fixture
-
-
-
-
-
-
diff --git a/tests/framework-fixtures/nuxt-vite7/files/app/app.vue b/tests/framework-fixtures/nuxt-vite7/files/app/app.vue
new file mode 100644
index 000000000..8f62b8bf9
--- /dev/null
+++ b/tests/framework-fixtures/nuxt-vite7/files/app/app.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/tests/framework-fixtures/nuxt-vite7/files/pages/index.vue b/tests/framework-fixtures/nuxt-vite7/files/app/pages/index.vue
similarity index 100%
rename from tests/framework-fixtures/nuxt-vite7/files/pages/index.vue
rename to tests/framework-fixtures/nuxt-vite7/files/app/pages/index.vue
diff --git a/tests/framework-fixtures/nuxt-vite7/files/nuxt.config.ts b/tests/framework-fixtures/nuxt-vite7/files/nuxt.config.ts
index e00a9dc6e..5d3eb6965 100644
--- a/tests/framework-fixtures/nuxt-vite7/files/nuxt.config.ts
+++ b/tests/framework-fixtures/nuxt-vite7/files/nuxt.config.ts
@@ -1,4 +1,5 @@
export default defineNuxtConfig({
+ compatibilityDate: '2025-07-15',
devtools: { enabled: false },
ssr: false,
});
diff --git a/tests/framework-fixtures/nuxt-vite7/fixture.json b/tests/framework-fixtures/nuxt-vite7/fixture.json
index 556521993..ecd24b78c 100644
--- a/tests/framework-fixtures/nuxt-vite7/fixture.json
+++ b/tests/framework-fixtures/nuxt-vite7/fixture.json
@@ -1,18 +1,38 @@
{
- "name": "Nuxt 4 + Vue 3 (static fixture only — runtime inject unsupported)",
+ "name": "Nuxt 4 + Vue 3",
"config": {
- "files": ["app.vue"],
- "insertBefore": "