mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Sync generated provider output
This commit is contained in:
@@ -15,10 +15,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -114,7 +114,7 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -139,7 +139,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -156,7 +158,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -595,11 +597,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .claude/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .cursor/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .gemini/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .gemini/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .github/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .grok/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .grok/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .kiro/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .kiro/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .opencode/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count E
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .opencode/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .pi/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT_C
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .pi/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .qoder/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVEN
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .rovodev/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EV
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .rovodev/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .trae-cn/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EV
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .trae-cn/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
|
||||
/**
|
||||
* Detect a browser that missed the generation `done` broadcast.
|
||||
*
|
||||
* The preflight scaffold write triggers a framework full-reload (Astro reloads
|
||||
* the page for any .astro edit). If the agent's variant write + `done` land
|
||||
* while the browser is mid-reload, the new page misses both the second HMR
|
||||
* reload and the SSE `done` — it resumes from the scaffold-only source and
|
||||
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
|
||||
* The preflight no longer writes the scaffold into source for source-preview
|
||||
* targets (the agent writes wrapper + variants in one atomic edit), so the old
|
||||
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
|
||||
* This recovery stays as defense in depth: any framework reload that drops the
|
||||
* agent's variant write + `done` while the browser is mid-reload leaves the new
|
||||
* page in GENERATING at 0/N. That resumed page always checkpoints
|
||||
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
|
||||
* missing" for a session whose generation already completed is direct
|
||||
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
|
||||
|
||||
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
const pageUrl = argVal(args, '--page-url');
|
||||
// Preflight passes this for source-preview targets. It computes the scaffold
|
||||
// (element location + wrapper text) but does NOT write it into source. The
|
||||
// agent then writes the wrapper + all variants in one atomic edit. The
|
||||
// premature server-side write full-reloaded the framework mid-generate and
|
||||
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
|
||||
// a no-op on the svelte-component path, which never writes the route source.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!elementId && !classes && !query) {
|
||||
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
|
||||
// scaffold here first would reload the framework before the agent's write
|
||||
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
|
||||
replaceEndLine: endLine + 1, // agent's wrapper block replaces
|
||||
};
|
||||
// insertLine matches the final file position the wrapper occupies once the
|
||||
// agent replaces the picked range, so downstream consumers stay consistent.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
// 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.
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
|
||||
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir) {
|
||||
// Per-target cache of the resolved source file. The wrap search walks the whole
|
||||
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
|
||||
// generate for the same picked element (re-rolls, param passes). Keyed by the
|
||||
// target signature (locator + route), so it invalidates automatically when the
|
||||
// element or route changes; a failed resolution evicts its entry (see below).
|
||||
const sourceResolutionCache = new Map();
|
||||
|
||||
/** Test/lifecycle hook: drop all cached source resolutions. */
|
||||
export function clearSourceResolutionCache() {
|
||||
sourceResolutionCache.clear();
|
||||
}
|
||||
|
||||
function targetSignature(event) {
|
||||
const isInsert = event.mode === 'insert';
|
||||
const target = isInsert ? insertTarget(event) : replaceTarget(event);
|
||||
return JSON.stringify({
|
||||
mode: isInsert ? 'insert' : 'replace',
|
||||
position: isInsert ? target.position : null,
|
||||
elementId: target.elementId || null,
|
||||
classes: target.classes || null,
|
||||
tag: target.tag || null,
|
||||
pageUrl: event.pageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
|
||||
if (!event || event.type !== 'generate' || !event.id) return null;
|
||||
|
||||
const isInsert = event.mode === 'insert';
|
||||
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
|
||||
|
||||
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
|
||||
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
|
||||
// Compute the scaffold but do not write it into source for source-preview
|
||||
// targets. The agent writes wrapper + variants atomically; a premature
|
||||
// server-side write reloads the framework and strands the browser at 0/N.
|
||||
// No-op on the svelte-component path, which never writes the route source.
|
||||
args.push('--defer-source-write');
|
||||
if (isInsert) args.push('--position', target.position);
|
||||
if (target.elementId) args.push('--element-id', target.elementId);
|
||||
if (target.classes) args.push('--classes', target.classes);
|
||||
if (target.tag) args.push('--tag', target.tag);
|
||||
if (target.text) args.push('--text', target.text);
|
||||
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace' };
|
||||
const signature = targetSignature(event);
|
||||
// A cached resolution points the helper straight at the file, skipping the
|
||||
// tree search. The helper still reads current content, so line ranges stay
|
||||
// fresh; only discovery is cached.
|
||||
const cachedFile = cache ? cache.get(signature) : null;
|
||||
if (cachedFile) args.push('--file', cachedFile);
|
||||
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
|
||||
scriptsDir,
|
||||
execFileImpl = execFileAsync,
|
||||
timeoutMs = PREFLIGHT_TIMEOUT_MS,
|
||||
cache = sourceResolutionCache,
|
||||
} = {}) {
|
||||
const command = buildGenerationPreflight(event, scriptsDir);
|
||||
const command = buildGenerationPreflight(event, scriptsDir, { cache });
|
||||
if (!command) {
|
||||
return { ok: false, skipped: true, reason: 'insufficient_locator' };
|
||||
}
|
||||
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
|
||||
});
|
||||
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
|
||||
if (!line) throw new Error('preflight returned no scaffold metadata');
|
||||
const scaffold = JSON.parse(line);
|
||||
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
|
||||
// the next generate on this target skips the tree search.
|
||||
const resolvedSource = scaffold.sourceFile || scaffold.file;
|
||||
if (cache && command.signature && typeof resolvedSource === 'string') {
|
||||
cache.set(command.signature, resolvedSource);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: command.mode,
|
||||
durationMs: performance.now() - startedAt,
|
||||
scaffold: JSON.parse(line),
|
||||
scaffold,
|
||||
};
|
||||
} catch (error) {
|
||||
// Evict a stale/failed resolution so the next attempt does a full search
|
||||
// (the element may have moved out of the previously cached file).
|
||||
if (cache && command.signature) cache.delete(command.signature);
|
||||
return {
|
||||
ok: false,
|
||||
mode: command.mode,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* TanStack Start live-mode adapter.
|
||||
*
|
||||
* TanStack Start is SSR: there is no static index.html to patch. The document
|
||||
* shell is a React component (`shellComponent`/`component`) defined in the root
|
||||
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
|
||||
* <Scripts /></body></html>`.
|
||||
*
|
||||
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
|
||||
* HTML, but React's script handling and hydration make it an unreliable place
|
||||
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
|
||||
* this keeps the injected code in a dev-only managed component that appends the
|
||||
* live script on mount (client-only, after hydration). The adapter mounts that
|
||||
* component from the root document and removes it cleanly on stop.
|
||||
*
|
||||
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
|
||||
* the TanStack Router file-based route generator never treats it as a route.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildLiveScriptSrc } from '../live-inject.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
|
||||
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
|
||||
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
|
||||
|
||||
const ROOT_ROUTE_CANDIDATES = [
|
||||
'src/routes/__root.tsx',
|
||||
'src/routes/__root.jsx',
|
||||
'src/routes/__root.ts',
|
||||
'src/routes/__root.js',
|
||||
'app/routes/__root.tsx',
|
||||
'app/routes/__root.jsx',
|
||||
];
|
||||
|
||||
const START_PACKAGES = [
|
||||
'@tanstack/react-start',
|
||||
'@tanstack/solid-start',
|
||||
'@tanstack/start',
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
|
||||
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
|
||||
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
|
||||
|
||||
return { rootRoute, componentFile, componentImport, ext };
|
||||
}
|
||||
|
||||
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('TanStack Start live adapter requires a numeric port');
|
||||
}
|
||||
|
||||
// Write the managed mount component.
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
|
||||
const componentExisted = fs.existsSync(componentAbs);
|
||||
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
|
||||
// A non-Impeccable file already sits at our managed path — refuse to clobber.
|
||||
return {
|
||||
file: project.componentFile,
|
||||
error: 'tanstack_component_conflict',
|
||||
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
|
||||
};
|
||||
}
|
||||
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
|
||||
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
|
||||
|
||||
// Patch the root document to import + render the mount component.
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = patchTanStackRoot(before, project.componentImport);
|
||||
const changed = after !== before;
|
||||
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
inserted: changed || !componentExisted,
|
||||
componentFile: project.componentFile,
|
||||
devOnly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
|
||||
if (!project) return { error: 'tanstack_not_detected' };
|
||||
let removed = false;
|
||||
|
||||
const rootAbs = path.join(cwd, project.rootRoute);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
const before = fs.readFileSync(rootAbs, 'utf-8');
|
||||
const after = unpatchTanStackRoot(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(rootAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const componentAbs = path.join(cwd, project.componentFile);
|
||||
if (fs.existsSync(componentAbs)) {
|
||||
fs.rmSync(componentAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: project.rootRoute,
|
||||
adapter: 'tanstack-start',
|
||||
removed,
|
||||
componentFile: project.componentFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchTanStackRoot(content, componentImport) {
|
||||
let out = String(content || '');
|
||||
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
|
||||
|
||||
if (!out.includes(importStatement)) {
|
||||
out = insertAfterLastImport(out, importStatement);
|
||||
}
|
||||
|
||||
if (!out.includes(TANSTACK_MARKER_OPEN)) {
|
||||
const block =
|
||||
`${TANSTACK_MARKER_OPEN}\n`
|
||||
+ ` <ImpeccableLiveRoot />\n`
|
||||
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
|
||||
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
|
||||
// fall back to before </body>.
|
||||
const scriptsMatch = out.match(/<Scripts\b/);
|
||||
if (scriptsMatch) {
|
||||
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
|
||||
} else {
|
||||
const bodyClose = out.lastIndexOf('</body>');
|
||||
if (bodyClose !== -1) {
|
||||
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchTanStackRoot(content) {
|
||||
let out = String(content || '');
|
||||
// Remove exactly the inserted block (open marker → component → close marker →
|
||||
// trailing newline + the indent that leads back to the anchor). Leaving the
|
||||
// leading indent before the open marker intact hands it back to the anchor
|
||||
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
|
||||
const blockRe = new RegExp(
|
||||
escapeRegExp(TANSTACK_MARKER_OPEN)
|
||||
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
|
||||
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
|
||||
+ '\\r?\\n?[ \\t]*',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '');
|
||||
// Remove only the managed import line — not any following blank line.
|
||||
out = out.replace(
|
||||
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
|
||||
'',
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTanStackLiveRootComponent(port, token) {
|
||||
const liveSrc = buildLiveScriptSrc(Number(port), token);
|
||||
return `/* impeccable-live-tanstack-start */
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LIVE_SRC = '${liveSrc}';
|
||||
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
|
||||
|
||||
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
|
||||
// document, so this appends the live-mode bundle from the client after
|
||||
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
|
||||
// server, so there is no hydration mismatch.
|
||||
export default function ImpeccableLiveRoot() {
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const expected = new URL(LIVE_SRC, window.location.href).href;
|
||||
let script = document.querySelector(LIVE_SELECTOR);
|
||||
if (script && script.src === expected) return;
|
||||
if (script) script.remove();
|
||||
|
||||
script = document.createElement('script');
|
||||
script.src = LIVE_SRC;
|
||||
script.async = true;
|
||||
script.setAttribute('data-impeccable-live-tanstack', '');
|
||||
script.setAttribute('data-impeccable-live-script', 'true');
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.isConnected) script.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The managed mount component carries the `impeccable-live-tanstack` marker in
|
||||
// its leading comment and its script data-attribute; user files never do.
|
||||
function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
toFile.split(path.sep).join('/'),
|
||||
).replace(/\.(tsx|ts|jsx|js)$/, '');
|
||||
return rel.startsWith('.') ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
function insertAfterLastImport(content, importStatement) {
|
||||
const importRe = /^import\b[^\n]*\n/gm;
|
||||
let lastEnd = -1;
|
||||
let m;
|
||||
while ((m = importRe.exec(content)) !== null) {
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd === -1) {
|
||||
return `${importStatement}\n${content}`;
|
||||
}
|
||||
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
|
||||
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
|
||||
|
||||
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
|
||||
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
|
||||
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
|
||||
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
|
||||
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
|
||||
8. On `exit`: run the cleanup at the bottom.
|
||||
|
||||
Harness policy:
|
||||
@@ -112,7 +112,7 @@ node .trae/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
|
||||
- `--position` ← `event.insert.position` (`before` | `after`)
|
||||
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
@@ -137,7 +137,9 @@ Reading annotations precisely:
|
||||
|
||||
### 2. Wrap the element
|
||||
|
||||
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
|
||||
|
||||
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
|
||||
|
||||
```bash
|
||||
node .trae/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
|
||||
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
|
||||
|
||||
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
@@ -593,11 +595,15 @@ Schema:
|
||||
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
|
||||
| Nuxt | `["app.vue"]` | `</body>` | `html` |
|
||||
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
|
||||
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
|
||||
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
|
||||
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
|
||||
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
|
||||
|
||||
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
|
||||
|
||||
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
|
||||
|
||||
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
|
||||
|
||||
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
|
||||
|
||||
@@ -127,6 +127,41 @@
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let generationPhase = null;
|
||||
// Ascending order of the agent-generation lifecycle. The visible progress bar
|
||||
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
|
||||
// earlier phase (the server regresses the snapshot phase to `generating` on a
|
||||
// behind checkpoint), and without this the bar jumps backward mid-generation.
|
||||
// Unranked phases (params sidecar flow, unknown values) always pass so we
|
||||
// never block a phase we do not model.
|
||||
const PHASE_RANK = {
|
||||
queued: 0,
|
||||
picked_up: 1,
|
||||
scaffolding: 2,
|
||||
scaffold_fallback: 3,
|
||||
source_ready: 4,
|
||||
generation_ready: 5,
|
||||
generating: 5,
|
||||
variants_progress: 5,
|
||||
first_variant_generating: 6,
|
||||
first_variant_validating: 7,
|
||||
first_reviewable: 8,
|
||||
remaining_variants_generating: 9,
|
||||
remaining_variants_validating: 10,
|
||||
second_reviewable: 11,
|
||||
all_variants_ready: 12,
|
||||
variants_ready: 12,
|
||||
variant_parameters_generating: 13,
|
||||
variant_parameters_validating: 14,
|
||||
parameters_ready: 15,
|
||||
};
|
||||
function shouldAdvancePhase(current, next) {
|
||||
if (!next || next === current) return false;
|
||||
const nextRank = PHASE_RANK[next];
|
||||
const currentRank = PHASE_RANK[current];
|
||||
// Only block a known-lower phase from overwriting a known-higher one.
|
||||
if (nextRank === undefined || currentRank === undefined) return true;
|
||||
return nextRank >= currentRank;
|
||||
}
|
||||
let parameterGenerationState = 'idle';
|
||||
let parameterReadyAnnouncedSession = null;
|
||||
let svelteComponentSession = null;
|
||||
@@ -6347,7 +6382,10 @@
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
generationPhase = msg.phase || generationPhase;
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
// checkpoint may carry an earlier phase for internal bookkeeping,
|
||||
// but the bar must not move backward.
|
||||
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
|
||||
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
|
||||
parameterGenerationState = 'loading';
|
||||
}
|
||||
@@ -6363,22 +6401,19 @@
|
||||
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
|
||||
rememberSessionFileMeta(msg);
|
||||
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
|
||||
// Component-preview (Svelte/Vue) progressive delivery: the browser
|
||||
// mounts compiled components, so there is no framework-owned DOM
|
||||
// to race. Keep streaming each checkpoint into the preview.
|
||||
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
|
||||
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
|
||||
// Give normal framework HMR the first chance to reconcile its
|
||||
// own managed tree. Nuxt route-module HMR can skip intermediate
|
||||
// revisions, so fall back to source injection only when the
|
||||
// advertised progress still has not appeared after a short
|
||||
// settle. Immediate injection races React/Vue ownership and can
|
||||
// trigger removeChild errors on the next HMR commit.
|
||||
const targetArrived = Number(msg.arrivedVariants) || 1;
|
||||
setTimeout(() => {
|
||||
if (msg.id !== currentSessionId) return;
|
||||
if (state !== 'GENERATING' && state !== 'CYCLING') return;
|
||||
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
|
||||
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
|
||||
}, 150);
|
||||
}
|
||||
// Source-preview targets: do NOT source-inject per checkpoint.
|
||||
// Immediate injection races framework (React/Vue) ownership mid-
|
||||
// generation and triggers removeChild errors on the next HMR
|
||||
// commit. Let HMR own reconciliation while variants stream in;
|
||||
// source injection runs only on the final `done` (which keeps its
|
||||
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
|
||||
// The visible progress count still advances from the variant
|
||||
// MutationObserver as HMR lands each variant.
|
||||
}
|
||||
break;
|
||||
case 'steer_done':
|
||||
@@ -6489,7 +6524,7 @@
|
||||
function handleServerLost() {
|
||||
const recoveryState = currentSessionId ? state : 'IDLE';
|
||||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||||
showToast('Live server disconnected. Session ended.', 5000);
|
||||
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
|
||||
}
|
||||
hideBar();
|
||||
hideHighlight();
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
import {
|
||||
applyTanStackLiveAdapter,
|
||||
detectTanStackStartProject,
|
||||
removeTanStackLiveAdapter,
|
||||
} from './live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
@@ -129,6 +134,7 @@ Output (JSON):
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
const nuxt = detectNuxtProject(process.cwd());
|
||||
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
@@ -136,6 +142,12 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
|
||||
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
|
||||
@@ -173,7 +185,7 @@ Output (JSON):
|
||||
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
const gitIgnore = ensureLiveGitIgnores(
|
||||
process.cwd(),
|
||||
nuxt ? [nuxt.pluginFile] : [],
|
||||
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
|
||||
);
|
||||
|
||||
if (svelteKit) {
|
||||
@@ -181,6 +193,18 @@ Output (JSON):
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
if (tanstack) {
|
||||
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
|
||||
console.log(JSON.stringify({
|
||||
ok: !adapterResult.error,
|
||||
port,
|
||||
adapter: 'tanstack-start',
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
}));
|
||||
if (adapterResult.error) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (nuxt) {
|
||||
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -131,6 +131,9 @@ Output (JSON):
|
||||
const query = argVal(args, '--query');
|
||||
const filePath = argVal(args, '--file');
|
||||
const text = argVal(args, '--text');
|
||||
// See live-wrap.mjs: preflight computes the scaffold but leaves source
|
||||
// untouched so the agent's single edit is the only framework reload.
|
||||
const deferSourceWrite = args.includes('--defer-source-write');
|
||||
|
||||
if (!id) { console.error('Missing --id'); process.exit(1); }
|
||||
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
|
||||
@@ -244,12 +247,23 @@ Output (JSON):
|
||||
isJsx,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let deferredWrapper = null;
|
||||
if (deferSourceWrite) {
|
||||
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
|
||||
// at the marker) at spliceIndex without removing any source line.
|
||||
deferredWrapper = {
|
||||
block: wrapperLines.join('\n'),
|
||||
replaceStartLine: spliceIndex + 1,
|
||||
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
|
||||
};
|
||||
} else {
|
||||
const newLines = [
|
||||
...lines.slice(0, spliceIndex),
|
||||
...wrapperLines,
|
||||
...lines.slice(spliceIndex),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
const insertLine = spliceIndex + 3;
|
||||
|
||||
@@ -257,6 +271,10 @@ Output (JSON):
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: relTargetFile,
|
||||
sourceWritten: deferredWrapper ? false : undefined,
|
||||
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
|
||||
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user