Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept

A ground-up hardening of live mode, driven by a production session in a
nested-app monorepo that hit six distinct failure classes. Full design
rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now
has a mechanical fix and a regression test.

Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot
(keyed on dev-server configs, not monorepo brand markers), persists a
manifest, and every live CLI re-anchors onto it at startup, so a helper
run from the wrong directory can no longer fork session state. Context
files are discovered upward to the git root.

Render truth: variant_mounted / variant_mount_failed events give the
journal per-variant mount state; failures reach the agent's poll queue,
raise a persistent error card with Retry (no more localStorage wipe), and
an attach probe names root/dev-server mismatches explicitly. The browser
rehydrates from the server when localStorage is gone.

Svelte: the scaffolder now parses with the app's own svelte 5 compiler.
Control flow survives (an each collection crosses the contract as one
structured prop), keyed each blocks hydrate synthetic keys, and anything
a detached preview cannot support falls back to source-preview instead of
shipping a wrong scaffold. Preview modules live in per-publish revision
directories, defeating stale transform caches.

Accept: CSS is reconciled, not appended. Matching selectors are replaced,
params bake from params.json kinds, the compiler's unused-selector pass
prunes superseded rules (pre-existing dead rules protected), a selector-
loss postcondition refuses any write that would drop hand-written rules,
and live-complete refuses to finish while live plumbing remains in source.

Also: framework registry (live/frameworks/) with a crash-safe injection
journal, session-store snapshot caching with read-only reads, protocol
enum consolidation, steer Send button, honest DESIGN-panel empty states.

Testing: new unit suites (roots, AST scaffolder, accept CSS, accept
pipeline, framework conformance); e2e now fails on preview-tree 404s,
proves computed-style mount for every variant, drives the Tune panel
through baked params, and injects failures (broken mounts, republish,
storage loss). New runtime fixtures: monorepo-nested-vite (repo root !=
app root) and vite8-sveltekit-stateful (each blocks + state). Nightly
full-matrix cron. An independent adversarial review pass preceded this
commit; its blocker and major findings are fixed and regression-tested.

This work was produced with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 15:09:40 -07:00
co-authored by Claude Code
parent 839dd10079
commit 17dabf4b7e
72 changed files with 9254 additions and 862 deletions
+49 -31
View File
@@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs';
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { findSourceFile } from './live/source-search.mjs';
import { resolveSourceTraits } from './live/frameworks/index.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';
export async function wrapCli() {
const args = process.argv.slice(2);
@@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`);
.join('\n');
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
const useFrameworkComponent = useSvelteComponent;
// The registry says which files get component preview; the svelte-component
// module keeps the env escape hatch that turns it off.
const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component'
&& shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`);
let svelteSession = null;
let deferredWrapper = null;
let sveltePreviewFallback = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
// Keep generation source-neutral: agents write real variant components
// under the generated componentDir, the browser mounts them into the live
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
svelteSession = scaffoldSvelteComponentSession({
//
// The scaffold is AST-based and refuses markup a detached preview cannot
// support (component tags, bind:/use:, await blocks, bound nested each).
// Refusal falls back to the plain source-preview wrapper below: an
// HMR-resetting but CORRECT preview beats a detached wrong one.
const scaffolded = scaffoldSvelteComponentSession({
id,
count,
sourceFile: relTargetFile,
@@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`);
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
if (scaffolded && scaffolded.fallback === 'source-preview') {
sveltePreviewFallback = scaffolded.reason || 'unsupported markup';
} else {
svelteSession = scaffolded;
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
}
}
if (svelteSession) {
// component preview: outputs already set above
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
@@ -396,15 +414,19 @@ The agent should insert variant HTML at insertLine.`);
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
const componentPreviewActive = !!svelteSession;
const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null;
const componentSession = svelteSession;
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined;
const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined;
const previewMode = componentPreviewMode;
console.log(JSON.stringify({
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
sourceFile: componentPreviewActive ? relTargetFile : undefined,
previewMode,
previewFallback: sveltePreviewFallback
? { from: 'svelte-component', reason: sveltePreviewFallback }
: undefined,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
@@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`);
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
sourceEndLine: componentPreviewActive ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
@@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`);
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: componentPreviewMode || styleMode.mode,
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
styleTag: componentPreviewActive ? null : styleMode.styleTag,
cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
@@ -630,27 +652,22 @@ function attrEscapeDouble(str) {
.replace(/>/g, '&gt;');
}
/**
* Comment syntax, style mode, and preview strategy all come from the framework
* registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX
* comments, `.astro` needs global-prefixed preview CSS because Astro scopes
* component styles away from the generated wrappers, `.svelte` gets component
* preview. See live/frameworks/index.mjs for why extension and not project.
*/
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
// HTML, Vue, Svelte, Astro all use HTML comments
return { open: '<!--', close: '-->' };
return resolveSourceTraits(filePath).commentSyntax === 'jsx'
? { open: '{/*', close: '*/}' }
: { open: '<!--', close: '-->' };
}
function detectStyleMode(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.astro') {
return {
mode: 'astro-global-prefixed',
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
};
}
return {
mode: 'scoped',
styleTag: '<style data-impeccable-css="SESSION_ID">',
};
const traits = resolveSourceTraits(filePath);
return { mode: traits.styleMode, styleTag: traits.styleTag };
}
function buildCssSelectorPrefixExamples(styleMode, count) {
@@ -890,6 +907,7 @@ function findClosingLine(lines, start) {
// Auto-execute when run directly (node live-wrap.mjs ...)
const _running = process.argv[1];
if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
enterLiveRoot();
wrapCli();
}