diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35c7d4c26..ff7e534e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
+ # Nightly full live-e2e matrix. The smoke groups already gate every PR; the
+ # full sweep is too slow for that, so it runs once a day against main.
+ schedule:
+ - cron: '0 7 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -157,14 +161,16 @@ jobs:
name: live-e2e smoke (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
- if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch'
+ if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- group: platform
- fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
+ fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit
+ - group: svelte
+ fixtures: vite8-sveltekit-stateful
- group: react
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
steps:
@@ -225,14 +231,16 @@ jobs:
name: live-e2e full (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
- if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch'
+ if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
timeout-minutes: 25
strategy:
fail-fast: true
matrix:
include:
- group: platform
- fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
+ fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router
+ - group: svelte
+ fixtures: vite8-sveltekit,vite8-sveltekit-stateful
- group: react-a
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
- group: react-b
diff --git a/.impeccable/config.json b/.impeccable/config.json
index 84e557937..95ab71955 100644
--- a/.impeccable/config.json
+++ b/.impeccable/config.json
@@ -3,6 +3,7 @@
"ignoreRules": [],
"ignoreFiles": [
"tests/fixtures/**",
+ "tests/framework-fixtures/**",
"tests/detect-antipatterns.test.js"
],
"ignoreValues": [
diff --git a/CLAUDE.md b/CLAUDE.md
index ba558b181..db9bb2345 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -159,6 +159,12 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`.
+Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`):
+
+- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest.
+- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r/`, bumped by the server on every done-reply), not by file watching.
+- **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free.
+
The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic.
**LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`, which calls Claude (default Haiku 4.5) via `@anthropic-ai/sdk`. Requires `ANTHROPIC_API_KEY` in env; the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL=claude-sonnet-4-6` if Haiku produces unreliable JSON. Caching is on — live.md is the cacheable prefix, and after the first call subsequent fixtures pay only the cache-read rate. Pass rate on a typical sweep is 18/19; the modal fixture's intrinsic state-loss flake is amplified by LLM latency and may need a re-run. **This path hits the API and costs money** — keep it out of CI unless you really want it there.
diff --git a/bun.lock b/bun.lock
index b6b622322..4e2861ecb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,7 @@
"ai": "^7.0.14",
"archiver": "^8.0.0",
"playwright": "^1.59.1",
+ "svelte": "^5",
"zod": "^4.3.6",
},
"optionalDependencies": {
@@ -74,6 +75,16 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
@@ -82,6 +93,12 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
@@ -90,6 +107,8 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
+
"ai": ["ai@7.0.31", "", { "dependencies": { "@ai-sdk/gateway": "4.0.23", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.11" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-pJfwKXjF5kw0rKRTePwYo60EfWb8wfzJAgf3ojln/YkOsVVKttzZAJVcRPsg37Z3a06ZdKkxX+DSrMAFlPm5Mw=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -102,8 +121,12 @@
"archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -142,6 +165,8 @@
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
"compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@@ -172,6 +197,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="],
+
"devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="],
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
@@ -202,6 +229,10 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="],
+
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
@@ -270,6 +301,8 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
@@ -290,6 +323,10 @@
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -398,6 +435,8 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+ "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="],
+
"tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="],
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
@@ -434,6 +473,8 @@
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
"zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
diff --git a/docs/LIVE-REWRITE-PLAN.md b/docs/LIVE-REWRITE-PLAN.md
new file mode 100644
index 000000000..431ec046f
--- /dev/null
+++ b/docs/LIVE-REWRITE-PLAN.md
@@ -0,0 +1,165 @@
+# Live v2: architecture plan
+
+> **Status (2026-07-27): implemented.** P0 through P3 landed in one pass: roots manifest (`skill/scripts/live/roots.mjs`, every live CLI re-anchors via `enterLiveRoot`), mount-ack protocol with per-variant render truth and persistent error card, server-first rehydration, AST scaffolder (`live/svelte-ast.mjs`) with source-preview fallback, unified mechanical accept (`live/accept-css.mjs`, compiler-pruned; `live-complete` refuses dirty source), revisioned preview dirs bumped per publish, attach probe with named root-mismatch diagnosis, monorepo runtime fixture, and e2e failing on preview-tree 404s. One deliberate deviation from section 3.3: the preview tree stays under `node_modules/.impeccable-live` because SvelteKit restricts vite `server.fs.allow` to src/lib, routes, .svelte-kit, and node_modules (verified: `.impeccable/` under the app root 403s); staleness is defeated by per-publish revision directories instead of watcher reliance. P4 (registry, consolidation, control-flow fixtures, nightly matrix) in flight.
+
+Driven by the 2026-07-25 Codex session in `~/code/agent-reviews` (session `019f9bf4-24a3-7661-afc5-bba7eb1e327f`), where a SvelteKit monorepo live session hit a chain of failures: wrong project root, silent 404 on variant modules, flattened `{#each}` blocks, stale module republish, unrecoverable browser state loss, and an accept that appended CSS instead of merging it. Every finding below was verified against current source; this is not a transcription of the Codex report.
+
+## 1. Verified findings (Codex claim → root cause in code)
+
+| # | Codex finding | Verified root cause |
+|---|---|---|
+| 1 | Wrong monorepo root | `findMonorepoRoot()` (`skill/scripts/context.mjs:262-303`) only recognizes "official" monorepos (workspaces field, turbo/pnpm/nx/lerna markers). A plain repo with a nested `website/` app falls into the non-monorepo branch and roots at cwd. `nearestTargetContextRoot` deliberately ignores `package.json` as a marker. Nothing ever correlates the dev server's root with `projectRoot`. |
+| 2 | Published mistaken for rendered | `arrivedVariants` is backfilled from `expectedVariants` on the agent's `--reply done` (`live/session-store.mjs:211-241`). On the component-preview path the browser never sends a mount checkpoint; success calls only `saveSession()` (`live-browser.js:5592`). There is no `mounted` / `mount_failed` anywhere in the protocol. |
+| 3 | Silent mount failures | Import catch is `console.error` + `return false` (`live-browser.js:5413-5419`). First-mount failure calls `abortSvelteComponentInjection` (`:5621-5651`), which clears localStorage, resets to PICKING, shows a 5s toast, and tells the server nothing. Variant-switch failure has no user feedback at all (`:4941-4950`). |
+| 4 | Svelte `{#each}` flattened | Prop extraction is pure regex (`live/svelte-component.mjs:18,44-88`). `{#each}` / `{/if}` block tokens become scalar string props (`prop0`, `prop5`); a 5-item loop scaffolds as one ``. A second, independent slot-shift bug: `buildSvelteExpressionTextMap` (`live-browser.js:5830-5871`) zips source tokens against live text nodes by index, and block tokens consume slots. |
+| 5 | Brittle temp module paths | Modules written to `/node_modules/.impeccable-live/` but imported from `location.origin` root-relative (`live-browser.js:5163-5165`). Two roots that must agree by convention. Manifest/CSS come from the helper's `/source`; the executable module from the dev server. Two transports for one artifact. |
+| 6 | Stale republish | Cache-bust is only a client-side `?t=Date.now()` on the leaf module (`live-browser.js:5377`). Vite's watcher ignores `node_modules`, so a rewrite of `vN.svelte` never invalidates the dev server's transform cache. Scaffold is write-once (`svelte-component.mjs:173`). No revision in the path; the runtime shim is memoized forever. |
+| 7 | Browser-local state is a single point of failure | Every restore path gates on localStorage first (`live-browser.js:8196-8246`). Server `activeSessions` only enrich an already-known local id. Mount failure deliberately wipes local state, manufacturing the orphaned-durable-session case. |
+| 8 | Parent context not discovered | Context root and project root are one variable. In the non-monorepo branch `repoRoot = absCwd` (`context.mjs:213`), so `website/` never looks one directory up for PRODUCT.md / DESIGN.md. The Codex session worked around it with symlinks that are still on disk. |
+| 9 | DESIGN panel disagrees with helper | Two causes: the panel is content-driven (needs frontmatter/sidecar, `live-browser.js:11037-11068`) while `hasDesign` is presence-driven; and the server snapshot of context is frozen at module load (`live-server.mjs:61-65`) while `live.mjs` re-resolves per boot and reuses a running server. |
+| 10 | Stop leaves `__runtime.js` | Sweeper skips non-directories and `__*` names (`svelte-component.mjs:732-742`). Confirmed still on disk in agent-reviews. Also unswept: accept receipts, session journals, deferred accepts in `os.tmpdir()`. |
+| 11 | CSS appended, not merged | `appendCssToSvelteStyle` (`svelte-component.mjs:278-296`) splices the variant CSS before the last ``. No reconciliation exists. Worse: `mergeOriginalTopLevelAttrs` (`:646-681`) copies the original root's classes onto the new markup, guaranteeing stale rules keep matching. |
+| 12 | Incomplete param baking | `sanitizeAcceptedSvelteCss` early-returns unless CSS contains `data-impeccable-variant` (`svelte-component.mjs:311-312`), which authoring rules forbid on this path, so the entire steps/toggle pruning pipeline is unreachable dead code. Only `range` vars get substituted; `toggle` bakes the raw JS boolean (`true`) into CSS. |
+| 13 | Formatting destroyed | `line.trimStart()` + one flat indent for every markup line (`svelte-component.mjs:544-547`); every CSS line reindented to 2 spaces (`:280`). No formatter anywhere. The Branch-H (HTML/JSX) path preserves relative indent; the Svelte path regressed against its own sibling. |
+| 14 | Preview cascade ≠ accepted cascade | Svelte scoping is compile-time per file; a detached preview component inherits none of the route's scoped CSS, so variants reimplement everything. The runtime then injects the variant CSS a second time, re-prefixed and un-hashed (`live-browser.js:5220-5280`), with different specificity than the compiled copy. |
+| 18 | Steer affordance | No Send button; Enter-only (`live-browser.js:9558-9712`). Good loading state, no queue-position feedback, 120s timeout blames the wrong component. The element-level Go bar has a visible submit button; the steer bar doesn't. |
+
+Findings 15-17 (hook anti-pattern gaps, copy quality, visual-collision detection) are design-hook scope, not live scope; tracked separately.
+
+Structural facts that explain why these all shipped:
+
+- The 826-line `svelte-component.mjs`, which owns scaffolding, CSS append, and param baking, has **zero direct unit tests**. The only runtime Svelte fixture (`vite8-sveltekit`) has no props, no `{#if}`, no `{#each}`, no `\n' +
- open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
- );
-}
-
-function detectLineEnding(content) {
- if (content.includes('\r\n')) return '\r\n';
- if (content.includes('\r')) return '\r';
- return '\n';
-}
-
-function normalizeLineEndings(content, lineEnding) {
- return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
-}
-
-function readLineEndingAt(content, index) {
- if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
- if (content[index] === '\n') return '\n';
- if (content[index] === '\r') return '\r';
- return '';
-}
-
-function insertTag(content, config, port, filePath, token) {
- const lineEnding = detectLineEnding(content);
- const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding);
- // insertBefore: match the LAST occurrence. Anchors like `
` open near the top of the document.
- const idx = content.indexOf(config.insertAfter);
- if (idx === -1) return content;
- const after = idx + config.insertAfter.length;
- // Preserve an existing trailing newline if the anchor already has one.
- // Slice the remainder from the original anchor offset, not prefix.length:
- // in the no-newline case prefix is one char longer than the anchor (the
- // appended '\n'), so slicing by prefix.length would drop the first real
- // character after the anchor (#227).
- const existingNewline = readLineEndingAt(content, after);
- const prefix = content.slice(0, after) + (existingNewline || lineEnding);
- const rest = content.slice(after + existingNewline.length);
- return prefix + block + rest;
-}
-
-/**
- * Remove the live script block. Matches either HTML or JSX comment markers
- * regardless of config (so stale tags from a wrong config can still be cleaned).
- *
- * Indent-preserving: captures any whitespace immediately preceding the opener
- * marker and re-emits it in place of the removed block. `insertTag` inserted
- * the block *after* the original line's indent and *before* the anchor (e.g.
- * `` naturally
- // belong at the end, and the same literal can appear earlier in code blocks
- // within rendered documentation pages.
- if (config.insertBefore) {
- const idx = content.lastIndexOf(config.insertBefore);
- if (idx === -1) return content;
- return content.slice(0, idx) + block + content.slice(idx);
- }
- // insertAfter: match the FIRST occurrence — typical anchors like `
` or
- // `
`), which moved the indent onto the opener line and left the anchor
- * unindented. Replacing the whole block (plus its trailing newline) with just
- * the captured indent hands the indent back to the anchor that follows.
- */
-function removeTag(content, _syntax) {
- const patterns = [
- /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/,
- /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
- ];
- for (const pat of patterns) {
- let changed = false;
- let next = content;
- do {
- content = next;
- next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
- if (/[\r\n]/.test(trailing)) return leadingIndent;
- return leadingIndent || trailing || '';
- });
- if (next !== content) changed = true;
- } while (next !== content);
- if (changed) return next;
- }
- return content;
-}
-
-// ---------------------------------------------------------------------------
-// Content-Security-Policy meta-tag patcher
-//
-// When the user's HTML carries ``,
-// the cross-origin load of /live.js (and the SSE/POST connection back to
-// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
-//
-// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
-// and stash the original `content` value in a `data-impeccable-csp-original`
-// attribute (base64) so revert is exact.
-//
-// On remove: detect the marker attribute, decode it, restore the original
-// content value verbatim, drop the marker.
-//
-// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
-// shared helpers) is NOT patched here — those need framework-specific config
-// edits and are handled via the existing detect-csp.mjs reference output.
-// Only the in-source meta-tag form gets the auto-patch.
-// ---------------------------------------------------------------------------
-
-const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
-
-function findCspMetaTags(content) {
- const out = [];
- const tagRe = /]*?)\/?>/gis;
- let m;
- while ((m = tagRe.exec(content)) !== null) {
- const attrs = m[1];
- if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
- out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
- }
- return out;
-}
-
-function getAttr(attrs, name) {
- const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
- const m = attrs.match(re);
- return m ? { quote: m[1], value: m[2], full: m[0] } : null;
-}
-
-function appendOriginToDirective(csp, directive, origin) {
- const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
- const m = csp.match(re);
- if (m) {
- const tokens = m[4].trim().split(/\s+/);
- if (tokens.includes(origin)) return csp;
- return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
- }
- // Directive missing — add it. Use 'self' + origin so we don't inadvertently
- // narrow the policy compared to the default-src fallback (most users with
- // an explicit CSP have 'self' there).
- return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
-}
-
-export function patchCspMeta(content, port) {
- const tags = findCspMetaTags(content);
- if (tags.length === 0) return content;
- const origin = `http://localhost:${port}`;
-
- // Walk last-to-first so prior splices don't invalidate later indices.
- let result = content;
- for (let i = tags.length - 1; i >= 0; i--) {
- const tag = tags[i];
- const attrs = tag.attrs;
- if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
- const contentAttr = getAttr(attrs, 'content');
- if (!contentAttr) continue;
-
- const original = contentAttr.value;
- let patched = original;
- patched = appendOriginToDirective(patched, 'script-src', origin);
- patched = appendOriginToDirective(patched, 'connect-src', origin);
- // The shader overlay during 'generating' creates a screenshot via
- // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
- // those. Add `blob:` so the overlay doesn't throw a CSP violation.
- patched = appendOriginToDirective(patched, 'img-src', 'blob:');
- if (patched === original) continue;
-
- const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
- const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
- // The tagRe captures any whitespace between the last attribute and the
- // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
- // a replace would land it BEFORE that trailing space, leaving a double
- // space inside attrs and clobbering the space before `/>`. Split off
- // the trailing whitespace, splice the marker into the attribute body,
- // and re-append the original trailing whitespace so a self-closing
- // `` round-trips byte-for-byte.
- const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
- const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
- const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
- const newTag = tag.full.replace(attrs, newAttrs);
-
- result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
- }
- return result;
-}
-
-export function revertCspMeta(content) {
- const tags = findCspMetaTags(content);
- if (tags.length === 0) return content;
-
- let result = content;
- for (let i = tags.length - 1; i >= 0; i--) {
- const tag = tags[i];
- const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
- if (!origAttr) continue;
- const contentAttr = getAttr(tag.attrs, 'content');
- if (!contentAttr) continue;
-
- let originalValue;
- try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
- catch { continue; }
-
- const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
- let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
- // Drop the marker attribute and any single space immediately preceding it.
- newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
- const newTag = tag.full.replace(tag.attrs, newAttrs);
-
- result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
- }
- return result;
-}
-
// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------
const _running = process.argv[1];
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
+ enterLiveRoot();
injectCli();
}
-export { insertTag, removeTag, validateConfig, buildTagBlock };
-// patchCspMeta + revertCspMeta are exported above where they're defined.
+// Re-exported so long-standing importers (live.mjs, the adapter modules, the
+// test suites) keep their entry points while the implementations live in
+// live/frameworks/.
+export {
+ buildLiveScriptSrc,
+ buildTagBlock,
+ insertTag,
+ patchCspMeta,
+ removeTag,
+ revertCspMeta,
+ validateConfig,
+};
+export {
+ applyNuxtLiveAdapter,
+ buildNuxtPlugin,
+ detectNuxtProject,
+ removeNuxtLiveAdapter,
+} from './live/frameworks/nuxt.mjs';
diff --git a/skill/scripts/live-insert.mjs b/skill/scripts/live-insert.mjs
index b4c17cca1..8d5829ea4 100644
--- a/skill/scripts/live-insert.mjs
+++ b/skill/scripts/live-insert.mjs
@@ -26,6 +26,7 @@ import {
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -286,5 +287,6 @@ Output (JSON):
const _running = process.argv[1];
if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
+ enterLiveRoot();
insertCli();
}
diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs
index 19a8b0f9b..f87a4de45 100644
--- a/skill/scripts/live-poll.mjs
+++ b/skill/scripts/live-poll.mjs
@@ -14,6 +14,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
// Absolute path to a sibling script in this skill's scripts dir, so runtime
// error hints print a directly-runnable command instead of a placeholder.
@@ -412,5 +413,6 @@ export function normalizePollTypes(value) {
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
+ enterLiveRoot();
pollCli();
}
diff --git a/skill/scripts/live-resume.mjs b/skill/scripts/live-resume.mjs
index 74284d48a..04306ff0c 100644
--- a/skill/scripts/live-resume.mjs
+++ b/skill/scripts/live-resume.mjs
@@ -4,6 +4,7 @@
*/
import { createLiveSessionStore } from './live/session-store.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
@@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) {
return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
}
+/**
+ * The browser's render truth, folded into a small block the agent reads before
+ * it decides what to do. `arrivedVariants` only says the agent published;
+ * `renderState` says whether any of it reached a screen.
+ */
+export function renderSummary(snapshot = {}) {
+ return {
+ renderState: snapshot.renderState ?? null,
+ mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
+ mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
+ };
+}
+
+export function mountFailureAction(snapshot = {}) {
+ const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [];
+ const latest = failures[failures.length - 1];
+ if (!latest) return null;
+ const where = latest.url ? ` from ${latest.url}` : '';
+ const why = latest.error ? ` (${latest.error})` : '';
+ return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file for the queued variant_mount_failed event (or republish) so the browser retries.`;
+}
+
function parseArgs(argv) {
const out = { id: null };
for (let i = 0; i < argv.length; i++) {
@@ -75,20 +98,26 @@ export async function resumeCli() {
}
const pending = snapshot.pendingEvent || null;
- const nextAction = pending
- ? pending.type === 'manual_edit_apply'
- ? manualApplyResumeHint(pending)
- : `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
- : snapshot.phase === 'carbonize_required'
- ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
- : snapshot.phase === 'accept_requested'
- ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
- : `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
+ const render = renderSummary(snapshot);
+ // A failed render outranks the generic pending-event hint: the agent needs to
+ // know the user is staring at an error card, not at variants. A leased manual
+ // Apply still outranks both, because abandoning that lease loses user edits.
+ const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null;
+ const nextAction = pending?.type === 'manual_edit_apply'
+ ? manualApplyResumeHint(pending)
+ : mountAction || (pending
+ ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
+ : snapshot.phase === 'carbonize_required'
+ ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
+ : snapshot.phase === 'accept_requested'
+ ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
+ : `Inspect ${snapshot.id}; no pending agent event is currently queued.`);
- console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
+ console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
+ enterLiveRoot();
resumeCli();
}
diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs
index 113ad01b3..14a207508 100644
--- a/skill/scripts/live-server.mjs
+++ b/skill/scripts/live-server.mjs
@@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
-import { LIVE_COMMANDS } from './live/vocabulary.mjs';
+import {
+ LIVE_COMMANDS,
+ VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
+} from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -51,24 +54,46 @@ import {
} from './live/manual-apply.mjs';
import {
applyDeferredSvelteComponentAccepts,
+ bumpSvelteComponentPreviewRevision,
removeAllSvelteComponentSessions,
+ sweepInactiveSvelteComponentSessions,
} from './live/svelte-component.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
-// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
-// DESIGN.json fallback for existing projects.
-const PROJECT_CONTEXT = loadContext(process.cwd());
-const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
-const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
- ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
- : null;
+// Anchor the whole process on the live roots manifest before anything derives
+// a path from cwd. A server started from the wrong directory re-roots itself
+// onto the appRoot the boot decided on instead of minting a second project.
+const LIVE_ROOTS = enterLiveRoot(process.cwd());
+
+// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
+// that outlives an `impeccable document` run (or a context file created after
+// boot) reports current truth instead of a boot-time snapshot. The roots
+// manifest wins when the ambient resolution misses (nested app inheriting
+// repo-level context files).
+function resolveProjectContext() {
+ const ctx = loadContext(process.cwd());
+ const designPath = ctx.designPath
+ ? path.resolve(process.cwd(), ctx.designPath)
+ : (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
+ const hasProduct = ctx.hasProduct
+ || !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
+ return {
+ ...ctx,
+ hasProduct,
+ hasDesign: !!designPath,
+ resolvedDesignPath: designPath,
+ contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
+ designContextDir: ctx.designContextDir
+ || (designPath ? path.dirname(designPath) : null),
+ };
+}
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
// The browser checkpoints for several unrelated reasons (see checkpointPayload
// in live-browser.js). Only these two report that variant availability changed,
// and only they may drive variant_progress / the *_reviewable phases.
-const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
+const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
// ---------------------------------------------------------------------------
// Port detection
@@ -445,6 +470,11 @@ function summarizeActiveSessionForClient(snapshot = {}) {
generationCompletedAt: snapshot.generationCompletedAt ?? null,
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
+ // Render truth, so a browser with no localStorage can rehydrate to the
+ // same comparison the server already knows about.
+ mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
+ mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
+ renderState: snapshot.renderState ?? null,
};
}
@@ -618,7 +648,7 @@ function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state.
- return !!PROJECT_CONTEXT.hasProduct;
+ return !!resolveProjectContext().hasProduct;
}
function statOrNull(filePath) {
@@ -827,8 +857,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
- const mdPath = DESIGN_MD_PATH;
- const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
+ const projectContext = resolveProjectContext();
+ const mdPath = projectContext.resolvedDesignPath;
+ const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -997,7 +1028,13 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
- if (msg.type !== 'checkpoint') {
+ // `variant_mounted` is the happy path: it is journaled above so the
+ // snapshot carries render truth, but there is nothing for the agent to
+ // do about it, so it stays out of the poll queue and off the SSE bus.
+ // `variant_mount_failed` is the opposite: the agent published something
+ // the browser could not render, and only the agent can fix it, so it
+ // goes to the queue as a first-class event.
+ if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') {
enqueueEvent(msg);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1099,7 +1136,8 @@ function sessionFileMetadataFromPollReply(file) {
const base = { file: normalized };
const metadataFile = normalized;
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
- if (!metadataFile.includes('node_modules/.impeccable-live/')
+ if (!metadataFile.includes('.impeccable/live/previews/')
+ && !metadataFile.includes('node_modules/.impeccable-live/')
&& !metadataFile.includes('src/lib/impeccable/')
&& !metadataFile.includes('/.impeccable-live/')) return base;
@@ -1139,7 +1177,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
// New pollers send sourceEventType explicitly; default to generate only for
// older callers so a late worker cannot acknowledge a queued Accept.
- if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
+ if (msg.type === 'agent_done' || msg.type === 'done') {
+ // A `done` reply to a mount failure is the republish that unblocks the
+ // browser. Without this the ack would look for a `generate` that was
+ // already retired, the mount-failure event would stay queued, and the next
+ // poll would hand the same failure back to the agent forever.
+ if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
+ return 'generate';
+ }
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
// never sets sourceEventType on it (the poller is a fresh process that cannot
// know what it leased). Returning undefined here makes acknowledgePendingEvent
@@ -1264,6 +1309,15 @@ function handlePollPost(req, res) {
return;
}
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
+ // A publish (done reply carrying a component manifest) snapshots the
+ // variant files into a fresh revision dir before the browser is told:
+ // the import path changes every publish, so no transform cache can pin a
+ // stale compile of a republished module (node_modules is unwatched).
+ if (replyFileMeta.previewMode === 'svelte-component'
+ && msg.id
+ && (msg.type === 'done' || !msg.type)) {
+ try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
+ }
if (state.sessionStore && msg.id && !skipJournalReply) {
try {
const eventType = msg.type === 'steer_done'
@@ -1335,6 +1389,51 @@ function cleanupSvelteComponentSessionsBeforeExit() {
}
}
+/**
+ * A previous run that died without its shutdown hook leaves preview component
+ * dirs behind. Drop the ones whose session the store no longer considers
+ * active; anything still active is mid-generation and must survive a restart.
+ */
+function sweepOrphanSvelteComponentSessionsOnStartup() {
+ try {
+ const activeIds = (state.sessionStore?.listActiveSessions() || [])
+ .map((snapshot) => snapshot?.id)
+ .filter(Boolean);
+ const result = sweepInactiveSvelteComponentSessions(activeIds, process.cwd());
+ if (result.removed.length > 0 || result.removedRoot) {
+ console.log('[impeccable] swept orphaned Svelte component sessions:', JSON.stringify(result));
+ }
+ } catch (err) {
+ console.warn('[impeccable] Svelte component session sweep failed:', err.message);
+ }
+}
+
+// Accept receipts are a short-lived idempotency record for a single accept.
+// Nothing reads one after the session that wrote it is gone, so they only need
+// to outlive a crash-and-retry window.
+const ACCEPT_RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
+
+function sweepStaleAcceptReceiptsOnStartup() {
+ try {
+ const dir = path.join(getLiveDir(process.cwd()), 'accept-receipts');
+ if (!fs.existsSync(dir)) return;
+ const cutoff = Date.now() - ACCEPT_RECEIPT_MAX_AGE_MS;
+ let removed = 0;
+ for (const name of fs.readdirSync(dir)) {
+ if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
+ const file = path.join(dir, name);
+ try {
+ if (fs.statSync(file).mtimeMs >= cutoff) continue;
+ fs.rmSync(file, { force: true });
+ removed++;
+ } catch { /* non-fatal */ }
+ }
+ if (removed > 0) console.log(`[impeccable] removed ${removed} accept receipt(s) older than 14 days`);
+ } catch (err) {
+ console.warn('[impeccable] accept receipt retention sweep failed:', err.message);
+ }
+}
+
function applyLegacyDeferredAcceptsOnStartup() {
try {
const result = applyDeferredSvelteComponentAccepts(process.cwd());
@@ -1474,6 +1573,8 @@ manualApply.rollbackTransaction({
reason: 'manual_edit_server_start_recovered_abandoned_transaction',
});
applyLegacyDeferredAcceptsOnStartup();
+sweepOrphanSvelteComponentSessionsOnStartup();
+sweepStaleAcceptReceiptsOnStartup();
restorePendingEventsFromStore();
manualApply.pruneStaleEvidence();
const portArg = args.find(a => a.startsWith('--port='));
diff --git a/skill/scripts/live-status.mjs b/skill/scripts/live-status.mjs
index dc98b7386..6ed2b890a 100644
--- a/skill/scripts/live-status.mjs
+++ b/skill/scripts/live-status.mjs
@@ -5,7 +5,8 @@
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
-import { manualApplyResumeHint } from './live-resume.mjs';
+import { manualApplyResumeHint, mountFailureAction, renderSummary } from './live-resume.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
function readServerInfo() {
return readLiveServerInfo(process.cwd())?.info || null;
@@ -28,6 +29,8 @@ export async function statusCli() {
const store = createLiveSessionStore({ cwd: process.cwd() });
const activeSessions = store.listActiveSessions();
const manualApply = findPendingManualApply(server, activeSessions);
+ const sessions = server?.activeSessions || activeSessions;
+ const renderFailure = sessions.find((session) => session?.renderState === 'failed') || null;
const payload = {
liveServer: server ? {
status: server.status,
@@ -36,14 +39,16 @@ export async function statusCli() {
agentPolling: server.agentPolling,
pendingEvents: server.pendingEvents,
} : null,
- activeSessions: server?.activeSessions || activeSessions,
- recoveryHint: recoveryHint({ server, manualApply }),
+ activeSessions: sessions,
+ render: sessions.map((session) => ({ id: session?.id ?? null, ...renderSummary(session) })),
+ recoveryHint: recoveryHint({ server, manualApply, renderFailure }),
};
console.log(JSON.stringify(payload, null, 2));
}
-function recoveryHint({ server, manualApply }) {
+function recoveryHint({ server, manualApply, renderFailure }) {
if (manualApply) return manualApplyResumeHint(manualApply);
+ if (renderFailure) return mountFailureAction(renderFailure);
if (server) {
return 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id after manual cleanup.';
}
@@ -61,5 +66,6 @@ function findPendingManualApply(server, activeSessions) {
const _running = process.argv[1];
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
+ enterLiveRoot();
statusCli();
}
diff --git a/skill/scripts/live-wrap.mjs b/skill/scripts/live-wrap.mjs
index 090ad30e9..55e83536f 100644
--- a/skill/scripts/live-wrap.mjs
+++ b/skill/scripts/live-wrap.mjs
@@ -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, '>');
}
+/**
+ * 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: '' };
+ return resolveSourceTraits(filePath).commentSyntax === 'jsx'
+ ? { open: '{/*', close: '*/}' }
+ : { open: '' };
}
function detectStyleMode(filePath) {
- const ext = path.extname(filePath).toLowerCase();
- if (ext === '.astro') {
- return {
- mode: 'astro-global-prefixed',
- styleTag: '\n`;
}
+/**
+ * Scaffold a component-preview session. The scaffold is AST-based: the app's
+ * own svelte compiler parses the selected markup, control-flow blocks are
+ * preserved (an each collection crosses the prop contract as ONE structured
+ * prop, its loop body verbatim), and constructs a detached preview cannot
+ * support return `{ fallback: 'source-preview', reason }` so the caller keeps
+ * the markup inside the route file instead of shipping a wrong preview.
+ */
export function scaffoldSvelteComponentSession({
id,
count,
@@ -145,17 +191,31 @@ export function scaffoldSvelteComponentSession({
originalLines,
cwd = process.cwd(),
}) {
+ const originalMarkup = originalLines.join('\n');
+
+ const compiler = loadSvelteCompiler(cwd);
+ if (!compiler) {
+ return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' };
+ }
+ const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse);
+ if (!analysis.ok) {
+ return { fallback: 'source-preview', reason: analysis.reason };
+ }
+
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
- const originalMarkup = originalLines.join('\n');
- const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
- const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
+ const contract = analysis.contract;
+ const seededCss = extractMatchingSourceCss(
+ safeReadSource(path.resolve(cwd, sourceFile)),
+ originalMarkup,
+ );
const manifest = {
id,
previewMode: 'svelte-component',
+ contractVersion: 2,
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
@@ -163,7 +223,14 @@ export function scaffoldSvelteComponentSession({
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ // Absolute paths let the browser fall back to /@fs/ imports when the dev
+ // server's base or root makes root-relative URLs miss, and probe whether
+ // the preview tree is reachable at all before blaming a variant.
+ componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -171,7 +238,7 @@ export function scaffoldSvelteComponentSession({
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
- fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
+ fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8');
}
}
@@ -183,6 +250,59 @@ export function scaffoldSvelteComponentSession({
};
}
+function safeReadSource(filePath) {
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
+}
+
+/**
+ * Seed variant stubs with the source component's rules that already style the
+ * selected markup, so variants start from the real cascade (a detached
+ * preview inherits none of the route's compile-scoped CSS) instead of
+ * reimplementing it blind.
+ */
+export function extractMatchingSourceCss(routeSource, originalMarkup) {
+ const styleMatch = String(routeSource || '').match(/\n`
+ : `\n\n`;
+ return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
+}
+
export function scaffoldSvelteComponentInsertSession({
id,
count,
@@ -213,7 +333,11 @@ export function scaffoldSvelteComponentInsertSession({
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -238,16 +362,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) {
if (fs.existsSync(direct)) {
return readManifest(direct);
}
- const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
- if (!fs.existsSync(root)) return null;
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const candidate = path.join(root, entry.name, 'manifest.json');
- if (!fs.existsSync(candidate)) continue;
- try {
- const manifest = readManifest(candidate);
- if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
- } catch { /* skip */ }
+ // Legacy location: a session scaffolded by an older version can still be
+ // accepted after an upgrade.
+ const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json');
+ if (fs.existsSync(legacyDirect)) {
+ return readManifest(legacyDirect);
+ }
+ for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
+ const root = path.join(cwd, rootRel);
+ if (!fs.existsSync(root)) continue;
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const candidate = path.join(root, entry.name, 'manifest.json');
+ if (!fs.existsSync(candidate)) continue;
+ try {
+ const manifest = readManifest(candidate);
+ if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
+ } catch { /* skip */ }
+ }
}
return null;
}
@@ -451,35 +583,6 @@ function rewriteParamSelectors(selector, paramValues) {
return { keep, selector: next };
}
-function splitSelectorList(prelude) {
- const selectors = [];
- let start = 0;
- let bracket = 0;
- let paren = 0;
- let quote = null;
- for (let i = 0; i < prelude.length; i++) {
- const ch = prelude[i];
- if (quote) {
- if (ch === '\\') i++;
- else if (ch === quote) quote = null;
- continue;
- }
- if (ch === '"' || ch === "'") {
- quote = ch;
- continue;
- }
- if (ch === '[') bracket++;
- else if (ch === ']') bracket = Math.max(0, bracket - 1);
- else if (ch === '(') paren++;
- else if (ch === ')') paren = Math.max(0, paren - 1);
- else if (ch === ',' && bracket === 0 && paren === 0) {
- selectors.push(prelude.slice(start, i));
- start = i + 1;
- }
- }
- selectors.push(prelude.slice(start));
- return selectors;
-}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
@@ -527,10 +630,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
+ const compiler = loadSvelteCompiler(cwd);
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
- const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
- .split('\n')
- .map((line) => line.trimEnd());
+
+ // Restore props back to route expressions. Contract v2 restores through the
+ // AST so a prop used without braces (each headers, attribute positions)
+ // still maps back to its original expression; v1 falls back to the textual
+ // placeholder swap.
+ let restoredText;
+ if (Number(manifest.contractVersion) === 2 && compiler) {
+ const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse);
+ if (!restored.ok) {
+ return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase };
+ }
+ restoredText = restored.markup;
+ } else {
+ restoredText = substitutePropsWithExprs(mergedMarkup, contract);
+ }
+ const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
@@ -541,10 +658,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
- const indentedMarkup = restoredMarkup.map((line) => {
- if (line.trim() === '') return '';
- return indent + line.trimStart();
- });
+ const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, start),
@@ -552,25 +666,145 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
...sourceLines.slice(end + 1),
];
- const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
- const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
- if (bakedCss.length > 0) {
- newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ // Selectors that were already unused before this accept are the user's
+ // pre-existing code; the pruning pass must not touch them.
+ const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set();
+
+ // Bake params (declared kinds from params.json drive branch pruning), then
+ // MERGE into the component's existing style block: matching selectors are
+ // replaced, new ones appended. Appending alone is how superseded rules used
+ // to survive their own replacement.
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ // Defensive: strip preview-wrapper selectors that authoring rules forbid
+ // on this path but an off-spec agent may still emit.
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ const cssStats = { replaced: 0, appended: 0, pruned: [] };
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
+ cssStats.replaced = merged.replaced;
+ cssStats.appended = merged.appended;
+ }
+
+ let finalText = newLines.join('\n');
+ if (compiler) {
+ const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused });
+ finalText = pruned.source;
+ cssStats.pruned = pruned.removed;
+ }
+
+ // Postcondition: no selector from the user's pre-accept CSS may vanish
+ // unless the compiler-driven prune deliberately removed it. This turns any
+ // parser or reconciler defect into a loud refusal instead of silent damage
+ // to a hand-written style block.
+ const lostSelectors = findLostSelectors(sourceContent, finalText, cssStats.pruned);
+ if (lostSelectors.length > 0) {
+ return {
+ handled: false,
+ error: 'CSS reconciliation would lose selectors from the existing style block: '
+ + lostSelectors.join(', ')
+ + '. Source not modified; accept the variant manually.',
+ mode: 'error',
+ ...resultBase,
+ };
}
try {
- fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
+ fs.writeFileSync(sourceFile, finalText, 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
+ const verify = verifyAcceptedSource(finalText);
return {
handled: true,
+ css: cssStats,
+ verify,
...resultBase,
};
}
+/** Re-indent a block onto `indent` while preserving its internal structure. */
+export function reindentPreservingStructure(lines, indent) {
+ const nonEmpty = lines.filter((line) => line.trim() !== '');
+ if (nonEmpty.length === 0) return lines.map(() => '');
+ const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length));
+ return lines.map((line) => {
+ if (line.trim() === '') return '';
+ const current = (line.match(/^\s*/) || [''])[0].length;
+ return indent + line.slice(Math.min(minIndent, current));
+ });
+}
+
+function styleBlockText(sourceText) {
+ const match = String(sourceText || '').match(/\n`,
+ replaced,
+ appended,
+ };
+ }
+
+ const inner = lastMatch[1];
+ const { css, replaced, appended } = reconcileCss(inner, incomingCss);
+ const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
+ const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`;
+ return {
+ text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length),
+ replaced,
+ appended,
+ };
+}
+
+function indentCssBlock(css) {
+ return String(css || '')
+ .split('\n')
+ .map((line) => (line.trim() === '' ? '' : ' ' + line))
+ .join('\n');
+}
+
function inlineSvelteComponentInsertAccept({
manifest,
markup,
@@ -601,10 +835,7 @@ function inlineSvelteComponentInsertAccept({
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
- const indentedMarkup = restoredMarkup.map((line) => {
- if (line.trim() === '') return '';
- return indent + line.trimStart();
- });
+ const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, insertIndex),
@@ -612,10 +843,15 @@ function inlineSvelteComponentInsertAccept({
...sourceLines.slice(insertIndex),
];
- const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
- const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
- if (bakedCss.length > 0) {
- newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
}
try {
@@ -625,8 +861,10 @@ function inlineSvelteComponentInsertAccept({
}
removeSvelteComponentSession(manifest.id, cwd);
+ const verify = verifyAcceptedSource(newLines.join('\n'));
return {
handled: true,
+ verify,
...resultBase,
};
}
@@ -729,18 +967,124 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
} catch { /* non-fatal */ }
}
+/**
+ * Snapshot the agent-authored variant files into a fresh revision directory
+ * and stamp the manifest. Called by the server on every publish (`done`
+ * reply) for a component session; the browser imports from the revision dir,
+ * so the dev server can never serve a stale compile of a republished file.
+ */
+export function bumpSvelteComponentPreviewRevision(id, cwd = process.cwd()) {
+ const manifest = findSvelteComponentManifest(id, cwd);
+ if (!manifest || !manifest.manifestPath) return null;
+ const sessionDir = path.dirname(manifest.manifestPath);
+ const revision = Number(manifest.revision || 0) + 1;
+ const revDirName = `r${revision}`;
+ const revDir = path.join(sessionDir, revDirName);
+ try {
+ fs.mkdirSync(revDir, { recursive: true });
+ let entries = [];
+ try { entries = fs.readdirSync(sessionDir, { withFileTypes: true }); } catch { /* empty */ }
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ if (entry.name === 'manifest.json') continue;
+ fs.copyFileSync(path.join(sessionDir, entry.name), path.join(revDir, entry.name));
+ }
+ // Previous revision dirs are dead the moment a new one exists.
+ for (const entry of entries) {
+ if (entry.isDirectory() && /^r\d+$/.test(entry.name) && entry.name !== revDirName) {
+ try { fs.rmSync(path.join(sessionDir, entry.name), { recursive: true, force: true }); } catch { /* non-fatal */ }
+ }
+ }
+ const relSessionDir = path.relative(cwd, sessionDir).split(path.sep).join('/');
+ const updated = {
+ ...manifest,
+ revision,
+ revisionDir: `${relSessionDir}/${revDirName}`,
+ revisionDirAbs: revDir.split(path.sep).join('/'),
+ };
+ delete updated.manifestPath;
+ fs.writeFileSync(manifest.manifestPath, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
+ return { revision, revisionDir: updated.revisionDir };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Stop-path sweep. The whole `node_modules/.impeccable-live` tree is
+ * impeccable-owned and gitignored, so once no session should survive there is
+ * nothing left worth keeping: the per-session dirs, the generated
+ * `__runtime.js`, and the parent directory all go. The old per-entry loop
+ * skipped `__*` entries and the parent, which left the runtime shim and an
+ * empty directory in every project that ever ran live mode once.
+ */
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
- const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
- if (!fs.existsSync(root)) return;
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- if (entry.name.startsWith('__')) continue;
+ for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
+ const root = path.join(cwd, rootRel);
+ if (!fs.existsSync(root)) continue;
try {
- fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
+ fs.rmSync(root, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
+/**
+ * Boot-path sweep. A restart must not delete the tree wholesale: sessions
+ * recorded in the session store may still be mid-generation. Remove only the
+ * session dirs whose id has no active snapshot, then drop `__runtime.js` and
+ * the parent directory when nothing is left to serve.
+ *
+ * @param {Iterable} activeIds session ids that must be preserved
+ * @returns {{ removed: string[], removedRoot: boolean, kept: string[] }}
+ */
+export function sweepInactiveSvelteComponentSessions(activeIds = [], cwd = process.cwd()) {
+ const result = { removed: [], removedRoot: false, kept: [] };
+ const active = new Set();
+ for (const id of activeIds || []) {
+ if (typeof id === 'string' && id) active.add(id);
+ }
+
+ for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
+ const root = path.join(cwd, rootRel);
+ if (!fs.existsSync(root)) continue;
+
+ let entries;
+ try {
+ entries = fs.readdirSync(root, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+
+ let keptHere = 0;
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ if (entry.name.startsWith('__')) continue;
+ if (active.has(entry.name)) {
+ result.kept.push(entry.name);
+ keptHere++;
+ continue;
+ }
+ try {
+ fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
+ result.removed.push(entry.name);
+ } catch {
+ // Could not remove it, so it still occupies the tree; treat it as kept
+ // so the parent directory is not torn out from under it.
+ result.kept.push(entry.name);
+ keptHere++;
+ }
+ }
+
+ if (keptHere === 0) {
+ try {
+ fs.rmSync(root, { recursive: true, force: true });
+ result.removedRoot = true;
+ } catch { /* non-fatal */ }
+ }
+ }
+ return result;
+}
+
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
diff --git a/skill/scripts/live/tanstack-adapter.mjs b/skill/scripts/live/tanstack-adapter.mjs
index c682914b1..4a1c81a97 100644
--- a/skill/scripts/live/tanstack-adapter.mjs
+++ b/skill/scripts/live/tanstack-adapter.mjs
@@ -19,7 +19,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { buildLiveScriptSrc } from '../live-inject.mjs';
+import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
diff --git a/skill/scripts/live/vocabulary.mjs b/skill/scripts/live/vocabulary.mjs
index 5c7b0b713..ce4e0927a 100644
--- a/skill/scripts/live/vocabulary.mjs
+++ b/skill/scripts/live/vocabulary.mjs
@@ -34,3 +34,138 @@ export const LIVE_COMMANDS = [
// Action values accepted by the live event protocol, in palette order.
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
+
+/*
+ * ---------------------------------------------------------------------------
+ * Protocol vocabulary
+ * ---------------------------------------------------------------------------
+ * The enums below are the wire contract between the browser overlay, the live
+ * helper server, and the durable session journal. They live here rather than in
+ * the modules that use them so a value cannot be added to the validator without
+ * the store and the server seeing it too.
+ *
+ * live-browser.js still cannot import this file (it is served raw and injected
+ * as an IIFE), so its local phase table repeats the agent-phase names. Anything
+ * the server can broadcast must appear in AGENT_PHASES here first.
+ */
+
+/**
+ * Phases the live server broadcasts as `agent_phase`, in lifecycle order.
+ * Every one of these is emitted by `recordAgentPhase()` in live-server.mjs;
+ * the validator rejects anything else, so a typo in a phase name fails loudly
+ * instead of quietly ranking as an unknown phase in the browser's progress bar.
+ */
+export const AGENT_PHASES = Object.freeze([
+ 'picked_up',
+ 'scaffolding',
+ 'source_ready',
+ 'scaffold_fallback',
+ 'generation_ready',
+ 'first_reviewable',
+ 'second_reviewable',
+ 'all_variants_ready',
+]);
+
+/** Event types the helper server accepts from the browser over POST /events. */
+export const CLIENT_EVENT_TYPES = Object.freeze([
+ 'generate',
+ 'accept',
+ 'discard',
+ 'checkpoint',
+ 'agent_phase',
+ 'variant_mounted',
+ 'variant_mount_failed',
+ 'exit',
+ 'prefetch',
+ 'manual_edits',
+ 'steer',
+ 'carbonize_cleanup',
+]);
+
+/**
+ * Event types the durable journal applies. A superset of CLIENT_EVENT_TYPES:
+ * the agent-side helpers (live-poll, live-complete) and the server itself
+ * append the rest. An event type missing here lands as `unknown_event_type`
+ * in the snapshot diagnostics.
+ */
+export const JOURNAL_EVENT_TYPES = Object.freeze([
+ 'generate',
+ 'variant_plan',
+ 'detector_waivers',
+ 'agent_phase',
+ 'variants_ready',
+ 'agent_done',
+ 'variant_mounted',
+ 'variant_mount_failed',
+ 'checkpoint',
+ 'accept',
+ 'accept_intent',
+ 'manual_edit_apply',
+ 'steer',
+ 'steer_done',
+ 'carbonize_cleanup',
+ 'discard',
+ 'discarded',
+ 'complete',
+ 'agent_error',
+]);
+
+/** Phases the session store assigns to a snapshot. */
+export const SESSION_PHASES = Object.freeze([
+ 'new',
+ 'generate_requested',
+ 'variants_ready',
+ 'carbonize_required',
+ 'carbonize_cleanup_requested',
+ 'manual_edit_apply_requested',
+ 'steer_requested',
+ 'steer_done',
+ 'accept_requested',
+ 'discard_requested',
+ 'discarded',
+ 'completed',
+ 'agent_error',
+]);
+
+/** Phases that retire a session from the active list. */
+export const COMPLETED_SESSION_PHASES = Object.freeze(['completed', 'discarded']);
+
+/**
+ * Phases after which a late generation write is a ghost from a canceled cycle.
+ * The store journals such an event as a diagnostic instead of applying it.
+ */
+export const GENERATION_FENCED_SESSION_PHASES = Object.freeze([
+ 'accept_requested',
+ 'discard_requested',
+ 'carbonize_required',
+ 'completed',
+ 'discarded',
+]);
+
+/**
+ * `reason` values carried on checkpoint events. Not validated (an unknown
+ * reason is journaled, never rejected) because the reason is diagnostic
+ * breadcrumb, not control flow. Two exceptions drive behavior and are split
+ * out below.
+ */
+export const CHECKPOINT_REASONS = Object.freeze([
+ 'generate_started',
+ 'variants_progress',
+ 'variants_ready',
+ 'browser_resumed',
+ 'browser_resumed_svelte_component',
+ 'param_changed',
+ 'variant_anchor_missing',
+ 'component_preview_anchor_missing',
+ 'steer_input_focused',
+ 'steer_submitted',
+ 'steer_send_failed',
+ 'steer_done',
+ 'steer_error',
+]);
+
+/** Checkpoint reasons the server reads as variant-publication progress. */
+export const VARIANT_PROGRESS_CHECKPOINT_REASONS = Object.freeze([
+ 'variants_progress',
+ 'variants_ready',
+]);
diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs
index 15be7cf41..112739427 100644
--- a/tests/framework-fixtures.test.mjs
+++ b/tests/framework-fixtures.test.mjs
@@ -12,7 +12,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
-import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -22,6 +22,7 @@ import { detectCsp } from '../skill/scripts/detect-csp.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = join(__dirname, '..', 'skill', 'scripts');
+const REPO_ROOT = join(__dirname, '..');
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
function listFixtures() {
@@ -46,6 +47,22 @@ function stageFixture(name) {
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
+ // The AST scaffolder resolves the app's svelte compiler from the staged
+ // root; the runtime suite gets it from a real npm install, the static sweep
+ // links this repo's devDependency so svelte fixtures take the
+ // component-preview path here too.
+ const repoSvelte = join(REPO_ROOT, 'node_modules', 'svelte');
+ if (existsSync(repoSvelte) && !existsSync(join(tmp, 'node_modules', 'svelte'))) {
+ mkdirSync(join(tmp, 'node_modules'), { recursive: true });
+ try {
+ symlinkSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), 'dir');
+ } catch {
+ // Windows without Developer Mode cannot symlink; copying is slower but
+ // keeps the suite runnable there.
+ cpSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), { recursive: true });
+ }
+ }
+
execFileSync('git', ['init', '-q'], { cwd: tmp });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp });
diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md
index bbbaa8c01..a4f871ebb 100644
--- a/tests/framework-fixtures/README.md
+++ b/tests/framework-fixtures/README.md
@@ -37,6 +37,7 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
},
"runtime": {
"styling": "plain-css | tailwind-v4 | styled-components | ...",
+ "appDir": "website",
"install": ["npm", "install"],
"devCommand": ["npm", "run", "dev"],
"scheme": "http",
@@ -44,6 +45,27 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
"readyTimeoutMs": 120000,
"pickSelector": "h1.hero-title",
+ "pickPosition": { "x": 10, "y": 10 },
+ "variantSequence": [3, 1, 2],
+ "acceptedSourcePattern": "]*class=\"[^\"]*\\bexpense-list\\b",
+ "assertSourceContains": ["{#each expenses as expense, i}"],
+ "stateProbe": {
+ "textSelector": "[data-testid='open-count']",
+ "expectedText": "3 offen",
+ "windowProperty": "__impeccableStatefulMounts",
+ "expectedWindowValue": 1,
+ "expectWindowUnchanged": true
+ },
+ "paramsScenario": {
+ "variant": 2,
+ "rangeLabel": "Lead",
+ "rangeValue": 1.8,
+ "stepsLabel": "Density",
+ "stepsOptionLabel": "Snug",
+ "expectSourceContains": ["line-height: 1.8"],
+ "expectSourceMissing": ["letter-spacing: 0.14em"]
+ },
+ "componentFailureScenarios": { "variant": 2, "storageLoss": false },
"mode": "insert",
"insert": {
"anchorSelector": "section#features",
@@ -84,10 +106,70 @@ The `runtime` block is optional. Fixtures without it only run the static unit ch
6. Runs a **Steer smoke** step (unless `runtime.steer === false`): submit a message in the global Steer bar, wait for the fake agent to reply `steer_done`, assert the bar unlocks and a `data-impeccable-steer` marker lands in source + DOM. Then continues with pick → Go → cycle → accept.
7. Tears everything down (Playwright close, dev server SIGTERM, live-server stop, tmp rm).
+### `runtime.appDir`
+
+Optional, defaults to `.`. Set it when the served app is **not** the repo root, the shape live mode has to resolve on its own (a CLI package at the root with the site in `website/`, for example). With `appDir` set, the harness:
+
+- stages `files/` and runs `git init` at the tmp root, as always;
+- writes `.impeccable/live/config.json` under `//`, and treats every fixture-relative path in `fixture.json` (`steer.sourceFile`, manual-edit `expectedSourceFile`, and so on) as relative to that app dir;
+- runs `runtime.install` and `runtime.devCommand` with the app dir as cwd;
+- boots through `live.mjs` **from the tmp root** instead of calling `live-server.mjs` and `live-inject.mjs` directly, so the run exercises root resolution (`skill/scripts/live/roots.mjs`) rather than assuming it. The parsed `live.mjs` payload is exposed as `session.liveBoot`, and `tests/live-e2e.test.mjs` asserts on `roots.appRoot`, `roots.contextRoot`, the persisted `roots.json`, and the repo-root pointer.
+
+The session object carries both paths: `session.tmp` is the repo root (use it for git and for artifact capture) and `session.appRoot` is the app. They are the same directory for every fixture without `appDir`.
+
+### Picking, cycling, and the render proof
+
+`pickSelector` names the element the run picks. The picker resolves whatever is
+under the cursor, so a **container whose centre is covered by a child can never
+be picked**: add `pickPosition` (`{x, y}` in px from the element's top-left) to
+aim at a point the container owns, such as its own padding.
+
+`variantSequence` (default `[2]`) is the order the run cycles through; the last
+entry is the variant it accepts. Every variant the run lands on gets a computed
+`font-weight` assertion in fake-agent mode, because the fake agent renders each
+variant at a distinct weight (`FAKE_VARIANT_FONT_WEIGHTS` in
+`tests/live-e2e/agent.mjs`: 300 / 900 / 600). That turns "variant N is visible"
+from a bar-label claim into a render fact, so a sequence like `[3, 1, 2]` proves
+all three variants really render.
+
+`acceptedSourcePattern` overrides the default post-accept source check (an ``), and `assertSourceContains` lists strings that must
+survive the whole wrap → accept → carbonize cycle. On Svelte component previews
+that is how a fixture proves control flow was not flattened: list the `{#each}`
+header and the per-item expressions.
+
+`stateProbe` asserts page state is not lost. `textSelector` / `expectedText`
+check rendered state; `windowProperty` with `expectedWindowValue` and
+`expectWindowUnchanged` check a counter the app bumps on mount, so a scaffold
+that silently remounted the page fails. It runs after preActions, after the
+variants land, and (outside component previews) after accept.
+
+### Extra scenarios
+
+Beyond the core cycle, a fixture opts into scenarios by declaring their config.
+Each one is also gated by `IMPECCABLE_E2E_SCENARIOS`:
+
+| Scenario name | Enabled by | What it proves |
+|---|---|---|
+| `params` | `runtime.paramsScenario` | Dial a `range` and a `steps` knob in the real Tune popover, accept, and assert the chosen values are baked into source as literals with the unchosen branch dropped and no `data-p-*` / `var(--p-*)` left behind. |
+| `mount-failure` | `runtime.componentFailureScenarios` | Corrupt the published `r/v` revision file, step onto it, and assert the persistent mount-error card appears, the session survives (bar + localStorage intact), and a `variant_mount_failed` event lands in the session journal. Then restore, Retry, and reach the variant again. |
+| `republish` | `runtime.componentFailureScenarios` | Re-author every variant and reply `done` again; the browser must mount the new content, which is what the server's revision-dir bump exists to guarantee. |
+| `storage-loss` | `runtime.componentFailureScenarios` (unless `storageLoss: false`) | Clear localStorage, reload, and assert the comparison comes back from the server's durable session record alone. |
+
+`componentFailureScenarios.variant` picks which variant to break or observe.
+Set `storageLoss: false` for fixtures whose picked element only exists after
+preActions: the reload discards that state, and re-creating it races the
+preview mount.
+
+These scenarios assert on deterministic content, so they skip under
+`IMPECCABLE_E2E_AGENT=llm`.
+
+One gotcha: `tests/framework-fixtures.test.mjs` stages the same fixture flat and writes the live config at the tmp root, so `config.files` has to resolve from both the repo root and the app root. A glob (`"**/index.html"`) satisfies both; a literal `"index.html"` only works from the app root and makes the static sweep report `file_not_found`.
+
Useful runtime E2E filters:
- `IMPECCABLE_E2E_ONLY=[,]` scopes the run to selected fixture names.
-- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, and exit probes.
+- `IMPECCABLE_E2E_SCENARIOS=core` runs only the main click → Go → cycle → accept path; omit it or use `all` to include manual edit, annotation, exit, params, and the component failure-injection probes. Names: `core`, `manual`, `annotations`, `exit`, `missed-done`, `params`, `mount-failure`, `republish`, `storage-loss`.
- `IMPECCABLE_E2E_TEST_TIMEOUT_MS`, `IMPECCABLE_E2E_INSTALL_TIMEOUT_MS`, and `IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS` tighten CI smoke timeouts without changing fixture metadata.
Optional `runtime.steer` fields:
@@ -112,6 +194,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`. |
+| `vite8-sveltekit-stateful/` | Svelte 5 route with `$state`, an `{#if}` branch, and an `{#each}` list. Picks the list container, so the component-preview scaffold has to carry the loop across as one `collection` prop and hydrate its items from the live DOM. Also carries the params, failure-injection, and state-preservation probes. |
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
| `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). |
| `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. |
@@ -120,5 +203,6 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
| `sveltekit-csp/` | SvelteKit `kit.csp.directives` in `svelte.config.js`. CSP shape `append-arrays`. |
| `nuxt-csp/` | Nuxt `routeRules` with literal CSP header in `nuxt.config.ts`. CSP shape `append-string`. |
+| `monorepo-nested-vite/` | Repo root is a CLI package with no dev config and no workspaces; the served Vite + React app lives in `website/`. Exercises `runtime.appDir` and live-mode root resolution. |
-Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
+Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`. A fixture with a `runtime` block also needs its name added to the live-e2e matrices in `.github/workflows/ci.yml`: the `live-e2e-full` group list, and one `live-e2e-smoke` group when it should run on every PR. Keep the groups roughly the same size, since they run in parallel and the job times out at 15 minutes.
diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md
new file mode 100644
index 000000000..d3e9e42e4
--- /dev/null
+++ b/tests/framework-fixtures/monorepo-nested-vite/files/DESIGN.md
@@ -0,0 +1,39 @@
+---
+name: Nested Website Fixture
+description: A one-page site for a command-line tool, set in plain type on paper-white surfaces.
+colors:
+ ink: "#142720"
+ paper: "#ffffff"
+ muted: "#555555"
+ hairline: "#dddddd"
+typography:
+ display:
+ fontFamily: "system-ui, sans-serif"
+ fontWeight: 700
+ lineHeight: 1.1
+ body:
+ fontFamily: "system-ui, sans-serif"
+ fontWeight: 400
+ lineHeight: 1.5
+rounded:
+ sm: "6px"
+ md: "8px"
+---
+
+# Design System: Nested Website Fixture
+
+## 1. Overview
+
+A single scrolling page on a paper-white ground. Hairline borders carry the structure; no fills, no shadows.
+
+## 2. Color
+
+Ink for text, paper for the page, muted grey for secondary copy, hairline grey for card borders.
+
+## 3. Typography
+
+System UI throughout. The hero title runs at 2rem and bold; body copy stays at the browser default size.
+
+## 4. Components
+
+Cards are bordered rectangles with 1rem of padding. Actions are inline text buttons with a hairline border and pill-free geometry.
diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md
new file mode 100644
index 000000000..86564cc0b
--- /dev/null
+++ b/tests/framework-fixtures/monorepo-nested-vite/files/PRODUCT.md
@@ -0,0 +1,24 @@
+# Product
+
+
+
+## Platform
+
+web
+
+## Users
+
+Maintainers of a small command-line tool who visit the project site to check what the tool does before installing it.
+
+## Product Purpose
+
+The repository ships a CLI. The marketing site in `website/` explains the CLI and is the only browser-facing surface here.
+
+## Capabilities and Constraints
+
+The repo root is a package with no dev server and no workspace declaration. Every runnable app lives one level down, in `website/`.
+
+## Product Principles
+
+- Say what the tool does before saying how it feels.
+- One page, read top to bottom, no navigation chrome.
diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/package.json b/tests/framework-fixtures/monorepo-nested-vite/files/package.json
new file mode 100644
index 000000000..bd512177b
--- /dev/null
+++ b/tests/framework-fixtures/monorepo-nested-vite/files/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "fake-cli-tool",
+ "private": true
+}
diff --git a/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html
new file mode 100644
index 000000000..c3fcf9c3a
--- /dev/null
+++ b/tests/framework-fixtures/monorepo-nested-vite/files/website/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Nested Website Fixture
+
+
+
+
+
+