chore(skills): configuration des compétences Impeccable pour les agents
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s

This commit is contained in:
2026-08-31 21:16:10 +02:00
parent 1c6c91d5ea
commit 89a3474512
904 changed files with 437336 additions and 0 deletions

View File

@@ -0,0 +1,617 @@
/**
* Accept-time CSS reconciliation for live mode.
*
* The old accept path appended the chosen variant's whole <style> body in
* front of the component's existing rules, which preserved every superseded
* declaration (the "old divider borders survive the accept" bug) and left
* dead parameter branches in source. This module makes acceptance a merge:
*
* reconcileCss replace rules whose selectors match, append new ones
* bakeParamValues collapse --p-* vars and [data-p-*] branches to the
* user's chosen values, driven by the declared param
* kinds from params.json (not regex sniffing)
* pruneUnusedSelectors use the framework compiler's own unused-selector
* warnings to delete rules the accepted markup no longer
* references
*
* The parser is hand-rolled on purpose: skill scripts run standalone inside
* user projects and cannot rely on this repo's node_modules. It is a small
* recursive block parser (comment- and string-aware), not a spec-complete
* CSS parser; everything it emits round-trips byte-for-byte through raw
* slices except the rules deliberately changed.
*/
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/**
* Parse a stylesheet into a flat tree.
* Node shapes:
* { type: 'rule', prelude, body, start, end, preludeStart }
* { type: 'at', name, prelude, children|body, start, end } (children when
* the block contains rules: media/supports/layer/container/scope)
* { type: 'comment', text, start, end }
*/
export function parseStylesheet(css, offset = 0) {
const text = String(css || '');
const nodes = [];
let i = 0;
const skipWs = () => { while (i < text.length && /\s/.test(text[i])) i++; };
while (i < text.length) {
skipWs();
if (i >= text.length) break;
if (text[i] === '/' && text[i + 1] === '*') {
const start = i;
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 2;
nodes.push({ type: 'comment', text: text.slice(start, i), start: offset + start, end: offset + i });
continue;
}
const preludeStart = i;
const boundary = scanToBlockOrStatementEnd(text, i);
if (boundary.kind === 'none') break; // trailing garbage / declarations at top level
if (boundary.kind === 'statement') {
// Block-less at-statement (@import, @charset, @layer names;). Emitted
// as its own node so the FOLLOWING rule still indexes for
// reconciliation instead of being folded into this prelude.
const raw = text.slice(preludeStart, boundary.index + 1).trim();
if (raw) {
nodes.push({
type: 'at',
name: (raw.match(/^@([A-Za-z-]+)/) || [])[1] || '',
prelude: raw.replace(/;$/, ''),
statement: true,
start: offset + preludeStart,
end: offset + boundary.index + 1,
});
}
i = boundary.index + 1;
continue;
}
const braceIdx = boundary.index;
const prelude = text.slice(preludeStart, braceIdx).trim();
const bodyStart = braceIdx + 1;
const bodyEnd = scanBlockEnd(text, bodyStart);
const body = text.slice(bodyStart, bodyEnd);
const nodeEnd = Math.min(text.length, bodyEnd + 1);
if (prelude.startsWith('@')) {
const name = (prelude.match(/^@([A-Za-z-]+)/) || [])[1] || '';
if (['media', 'supports', 'layer', 'container', 'scope'].includes(name)) {
nodes.push({
type: 'at',
name,
prelude,
children: parseStylesheet(body, offset + bodyStart),
start: offset + preludeStart,
end: offset + nodeEnd,
});
} else {
nodes.push({
type: 'at',
name,
prelude,
body,
start: offset + preludeStart,
end: offset + nodeEnd,
});
}
} else if (prelude) {
nodes.push({
type: 'rule',
prelude,
body,
start: offset + preludeStart,
end: offset + nodeEnd,
preludeStart: offset + preludeStart,
});
}
i = nodeEnd;
}
return nodes;
}
/**
* Scan for the next structural boundary: the `{` opening a block, or the `;`
* ending a block-less at-statement, whichever comes first (string- and
* comment-aware). Returns { kind: 'block' | 'statement' | 'none', index }.
*/
function scanToBlockOrStatementEnd(text, from) {
let i = from;
let quote = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 1;
} else if (ch === '{') {
return { kind: 'block', index: i };
} else if (ch === ';') {
return { kind: 'statement', index: i };
}
i++;
}
return { kind: 'none', index: -1 };
}
function scanBlockEnd(text, from) {
let i = from;
let depth = 1;
let quote = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (ch === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
i = close === -1 ? text.length : close + 1;
} else if (ch === '{') {
depth++;
} else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
i++;
}
return text.length;
}
export function serializeNodes(nodes, indent = '') {
const out = [];
for (const node of nodes) {
if (node.type === 'comment') {
out.push(indent + node.text);
} else if (node.type === 'rule') {
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
} else if (node.type === 'at' && node.children) {
out.push(`${indent}${node.prelude} {`);
out.push(serializeNodes(node.children, indent + ' '));
out.push(`${indent}}`);
} else if (node.type === 'at' && node.statement) {
out.push(`${indent}${node.prelude};`);
} else if (node.type === 'at') {
out.push(`${indent}${node.prelude} {${formatBody(node.body, indent)}}`);
}
}
return out.join('\n');
}
function formatBody(body, indent) {
const trimmed = String(body || '').trim();
if (!trimmed) return ' ';
const lines = trimmed.split('\n').map((l) => l.trim()).filter(Boolean);
if (lines.length === 1 && lines[0].length < 60) return ` ${lines[0]} `;
return '\n' + lines.map((l) => `${indent} ${l}`).join('\n') + `\n${indent}`;
}
export function normalizeSelector(prelude) {
return String(prelude || '')
.replace(/\s+/g, ' ')
.replace(/\s*([>+~,])\s*/g, '$1')
.trim();
}
// ---------------------------------------------------------------------------
// Reconciliation
// ---------------------------------------------------------------------------
/**
* Merge variant CSS into existing CSS. Rules whose (at-context, normalized
* selector) match an existing rule REPLACE that rule's body in place; new
* rules append at the end under their at-context. Returns { css, replaced,
* appended }.
*/
export function reconcileCss(existingCss, variantCss) {
const existing = parseStylesheet(existingCss);
const incoming = parseStylesheet(variantCss);
let replaced = 0;
let appended = 0;
const mergeLevel = (existingNodes, incomingNodes) => {
const index = new Map();
for (const node of existingNodes) {
if (node.type === 'rule') index.set(normalizeSelector(node.prelude), node);
}
const atIndex = new Map();
for (const node of existingNodes) {
if (node.type === 'at' && node.children) atIndex.set(normalizeSelector(node.prelude), node);
}
// Baking can leave several incoming rules with the same selector (e.g. a
// base rule plus a stripped param branch). The first one REPLACES the
// existing body; later same-selector rules extend it, never clobber it.
const touched = new Set();
for (const node of incomingNodes) {
if (node.type === 'comment') continue;
if (node.type === 'rule') {
const key = normalizeSelector(node.prelude);
const match = index.get(key);
if (match) {
if (touched.has(key)) {
match.body = `${match.body.trim()}\n${node.body.trim()}`;
} else if (match.body.trim() !== node.body.trim()) {
match.body = node.body;
replaced++;
}
touched.add(key);
} else {
// New base rules go BEFORE the existing top-level media blocks:
// appended after them, an equal-specificity base rule wins the
// cascade over the stylesheet's earlier responsive overrides and
// silently weakens the mobile styles for any still-shared class.
const appendedNode = { ...node };
const firstAt = existingNodes.findIndex((n) => n.type === 'at' && n.children);
if (firstAt === -1) existingNodes.push(appendedNode);
else existingNodes.splice(firstAt, 0, appendedNode);
index.set(key, appendedNode);
touched.add(key);
appended++;
}
} else if (node.type === 'at' && node.children) {
const key = normalizeSelector(node.prelude);
const match = atIndex.get(key);
if (match) {
mergeLevel(match.children, node.children);
} else {
existingNodes.push({ ...node });
atIndex.set(key, existingNodes[existingNodes.length - 1]);
appended++;
}
} else {
existingNodes.push({ ...node });
appended++;
}
}
};
mergeLevel(existing, incoming);
return { css: serializeNodes(existing), replaced, appended };
}
// ---------------------------------------------------------------------------
// Parameter baking
// ---------------------------------------------------------------------------
/**
* Replace every `var(--p-<id>, fallback)` / `var(--p-<id>)` occurrence with a
* literal value. Paren-aware: fallbacks containing calc()/nested vars are
* handled, unlike the old `[^)]+` regex.
*/
export function substituteParamVar(css, id, value) {
const text = String(css || '');
const needle = `var(--p-${id}`;
let out = '';
let i = 0;
while (i < text.length) {
const idx = text.indexOf(needle, i);
if (idx === -1) { out += text.slice(i); break; }
const after = idx + needle.length;
// Must be end of the var name: `)` or `,`.
if (after < text.length && text[after] !== ')' && text[after] !== ',') {
out += text.slice(i, after);
i = after;
continue;
}
let j = after;
let depth = 1; // we are inside var(
while (j < text.length && depth > 0) {
if (text[j] === '(') depth++;
else if (text[j] === ')') depth--;
j++;
}
out += text.slice(i, idx) + String(value);
i = j;
}
return out;
}
function normalizeToggleForVar(value) {
return value === true || value === 'true' || value === 1 || value === '1' || value === 'on' ? '1' : '0';
}
function isToggleOn(value) {
return normalizeToggleForVar(value) === '1';
}
/**
* Strip `[data-p-<id>="value"]` / `[data-p-<id>]` attribute selectors from a
* selector, deciding survival by the chosen value:
* returns null when the selector targets a non-chosen branch (drop it),
* otherwise the selector with the attribute test removed and any emptied
* :global() wrappers cleaned up.
*/
export function stripParamSelector(selector, id, kind, chosenValue) {
const attrRe = new RegExp(`\\[data-p-${escapeRegExp(id)}(?:=(["'])(.*?)\\1)?\\]`, 'g');
let drop = false;
let out = String(selector).replace(attrRe, (_m, _q, expected) => {
if (kind === 'steps') {
if (expected == null || String(expected) === String(chosenValue)) return '';
drop = true;
return '';
}
// toggle: the runtime sets data-p-<id>="on" when on and removes the
// attribute when off. A branch survives baking only if it actually
// matched at preview time with the chosen state: the presence form and
// the literal "on" form match while on; every other valued form
// (["false"], ["0"], ...) never matched and is dead regardless of state.
if (expected != null && expected !== 'on') {
drop = true;
return '';
}
if (!isToggleOn(chosenValue)) {
drop = true;
return '';
}
return '';
});
if (drop) return null;
out = out
.replace(/:global\(\s*\)/g, '')
.replace(/\s+/g, ' ')
.replace(/^\s*[>+~]\s*/, '')
.trim();
return out || null;
}
/**
* Bake chosen parameter values into CSS. `params` is the declared parameter
* list for the accepted variant (from params.json); `values` maps id ->
* chosen value (falling back to each param's declared default).
*/
export function bakeParamValues(css, params = [], values = {}) {
let nodes = parseStylesheet(css);
const chosen = new Map();
for (const param of params || []) {
if (!param || !param.id) continue;
const has = values && Object.prototype.hasOwnProperty.call(values, param.id);
chosen.set(param.id, { kind: param.kind, value: has ? values[param.id] : param.default });
}
// Values sent for params that were never declared still bake as ranges,
// so an out-of-sync manifest degrades to the old behavior, not to silence.
for (const [id, value] of Object.entries(values || {})) {
if (!chosen.has(id)) chosen.set(id, { kind: 'range', value });
}
const bakeBody = (body) => {
let out = String(body || '');
for (const [id, { kind, value }] of chosen) {
const literal = kind === 'toggle' ? normalizeToggleForVar(value) : String(value);
out = substituteParamVar(out, id, literal);
}
// Strip the readiness sentinel as a DECLARATION, not a line: a one-line
// rule carrying the sentinel plus real declarations must keep the rest.
return out
.replace(/(^|;)\s*--impeccable-variant-ready\s*:[^;{}]*/g, '$1')
.replace(/;\s*;/g, ';')
.replace(/^\s*;\s*/, '');
};
const transform = (list) => {
const result = [];
for (const node of list) {
if (node.type === 'at' && node.children) {
const children = transform(node.children);
if (children.length > 0) result.push({ ...node, children });
continue;
}
if (node.type !== 'rule') {
if (node.type === 'at') result.push({ ...node, body: bakeBody(node.body) });
else result.push(node);
continue;
}
const selectors = splitSelectorList(node.prelude);
const kept = [];
for (let selector of selectors) {
let alive = true;
for (const [id, { kind, value }] of chosen) {
if (kind !== 'steps' && kind !== 'toggle') continue;
if (!selector.includes(`data-p-${id}`)) continue;
const next = stripParamSelector(selector, id, kind, value);
if (next == null) { alive = false; break; }
selector = next;
}
if (alive && selector.trim()) kept.push(selector.trim());
}
if (kept.length === 0) continue;
const body = bakeBody(node.body);
if (!body.trim()) continue;
result.push({ ...node, prelude: kept.join(', '), body });
}
return result;
};
nodes = transform(nodes);
return serializeNodes(nodes);
}
export function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
const text = String(prelude || '');
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(text.slice(start, i));
start = i + 1;
}
}
selectors.push(text.slice(start));
return selectors.map((s) => s.trim()).filter(Boolean);
}
// ---------------------------------------------------------------------------
// Compiler-driven pruning
// ---------------------------------------------------------------------------
/**
* Remove selectors the framework compiler reports as unused from a full
* component source. `compileFn` is the app's svelte compile; warnings with
* code `css_unused_selector` carry character offsets into the source.
* `skipSelectors` protects selectors that were already unused before the
* accept: pre-existing dead rules are the user's code, not live-mode debris.
* Returns { source, removed } where removed lists the pruned selector texts.
*/
export function collectUnusedSelectors(componentSource, compileFn) {
try {
const { warnings } = compileFn(String(componentSource || ''), { generate: false });
return new Set((warnings || [])
.filter((w) => w.code === 'css_unused_selector'
&& Number.isInteger(w.start?.character)
&& Number.isInteger(w.end?.character))
.map((w) => String(componentSource).slice(w.start.character, w.end.character).trim()));
} catch {
return new Set();
}
}
export function pruneUnusedSelectors(componentSource, compileFn, { skipSelectors } = {}) {
let source = String(componentSource || '');
const removed = [];
const skip = skipSelectors instanceof Set ? skipSelectors : new Set(skipSelectors || []);
for (let pass = 0; pass < 3; pass++) {
let warnings;
try {
({ warnings } = compileFn(source, { generate: false }));
} catch {
return { source, removed }; // never let pruning break an accept
}
const unused = (warnings || [])
.filter((w) => w.code === 'css_unused_selector'
&& Number.isInteger(w.start?.character)
&& Number.isInteger(w.end?.character))
.filter((w) => !skip.has(source.slice(w.start.character, w.end.character).trim()))
.sort((a, b) => b.start.character - a.start.character);
if (unused.length === 0) break;
let next = source;
for (const warning of unused) {
const result = removeSelectorAt(next, warning.start.character, warning.end.character);
if (result.changed) {
removed.push(result.selector);
next = result.source;
}
}
if (next === source) break;
source = next;
}
return { source, removed };
}
/**
* Remove the selector at [start, end) from its rule. When it is the rule's
* only selector, remove the whole rule (prelude through closing brace).
*/
function removeSelectorAt(source, start, end) {
const selector = source.slice(start, end);
// Find the rule boundaries around the selector.
const braceIdx = source.indexOf('{', end);
if (braceIdx === -1) return { changed: false, selector, source };
const bodyEnd = scanBlockEnd(source, braceIdx + 1);
// Prelude spans backward from the brace to the previous } ; { or the end
// of the <style> open tag. A bare `>` is NOT a boundary: it is the child
// combinator, and cutting there truncates a selector list like
// `.a > .b, .c` mid-prelude. Only a `>` that closes a `<style ...>` tag
// bounds the walk.
let preludeStart = start;
for (let i = start - 1; i >= 0; i--) {
const ch = source[i];
if (ch === '}' || ch === '{' || ch === ';') { preludeStart = i + 1; break; }
if (ch === '>') {
const styleOpen = source.lastIndexOf('<style', i);
if (styleOpen !== -1 && source.indexOf('>', styleOpen) === i) { preludeStart = i + 1; break; }
continue; // child combinator inside the prelude
}
if (i === 0) preludeStart = 0;
}
const prelude = source.slice(preludeStart, braceIdx);
const selectors = splitSelectorList(prelude);
const target = selector.trim();
const kept = selectors.filter((s) => s !== target);
if (kept.length === selectors.length) {
// Offsets did not line up with a full selector in the list; be safe.
return { changed: false, selector, source };
}
if (kept.length === 0) {
// Remove the entire rule including trailing newline.
let ruleEnd = Math.min(source.length, bodyEnd + 1);
while (ruleEnd < source.length && source[ruleEnd] === '\n') ruleEnd++;
let ruleStart = preludeStart;
while (ruleStart > 0 && (source[ruleStart - 1] === ' ' || source[ruleStart - 1] === '\t')) ruleStart--;
return { changed: true, selector: target, source: source.slice(0, ruleStart) + source.slice(ruleEnd) };
}
const indent = (prelude.match(/^\s*/) || [''])[0];
return {
changed: true,
selector: target,
source: source.slice(0, preludeStart) + indent + kept.join(', ') + ' ' + source.slice(braceIdx, source.length),
};
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Collect every normalized selector in a CSS text, including inside nested
* at-blocks. Used by the accept postcondition: a selector present before the
* accept may only disappear if the compiler reported it unused; anything
* else means the parser or reconciler damaged the user's file, and the write
* must be refused rather than silently committed.
*/
export function collectAllSelectors(css, out = new Set()) {
for (const node of parseStylesheet(css)) {
if (node.type === 'rule') {
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
} else if (node.type === 'at' && node.children) {
for (const child of node.children) {
if (child.type === 'rule') {
for (const selector of splitSelectorList(child.prelude)) out.add(normalizeSelector(selector));
} else if (child.type === 'at' && child.children) {
collectSelectorsFromNodes(child.children, out);
}
}
}
}
return out;
}
function collectSelectorsFromNodes(nodes, out) {
for (const node of nodes) {
if (node.type === 'rule') {
for (const selector of splitSelectorList(node.prelude)) out.add(normalizeSelector(selector));
} else if (node.type === 'at' && node.children) {
collectSelectorsFromNodes(node.children, out);
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* Postcondition scanner for accepted/carbonized source. The carbonize
* contract used to exist only as prose in reference/live.md; nothing checked
* that an accept actually left the file clean, so dead param branches,
* preview attributes, and marker comments accumulated across sessions. This
* scanner is the mechanical form of that contract. live-complete refuses to
* mark a carbonize session complete while the file is dirty, and the
* mechanical Svelte accept runs it on its own output as a self-check.
*/
// Param patterns are anchored to the exact shapes live mode writes
// (attribute-with-value / selector forms, var() references), not bare
// substrings, so user tokens that merely share the prefix cannot trip the
// completion gate.
const FORBIDDEN = [
{ marker: 'impeccable-variants-start', why: 'variant wrapper comment left in source' },
{ marker: 'impeccable-variants-end', why: 'variant wrapper comment left in source' },
{ marker: 'impeccable-carbonize-start', why: 'carbonize block not rewritten into permanent form' },
{ marker: 'impeccable-carbonize-end', why: 'carbonize block not rewritten into permanent form' },
{ marker: 'impeccable-param-values', why: 'param-values comment not baked and removed' },
{ marker: 'data-impeccable-', why: 'live-mode plumbing attribute left on markup' },
{ marker: /\bdata-p-[A-Za-z0-9_-]+\s*(?:=|\])/, label: 'data-p-*', why: 'preview parameter attribute left on markup' },
{ marker: /var\(\s*--p-[A-Za-z0-9_-]+\s*[,)]/, label: 'var(--p-*)', why: 'preview parameter variable not baked to a literal' },
{ marker: '--impeccable-variant-ready', why: 'preview readiness sentinel left in CSS' },
];
/**
* Scan file text for live-mode leftovers. Returns { clean, findings } where
* each finding is { marker, line, excerpt, why }.
*/
export function verifyAcceptedSource(text) {
const findings = [];
const lines = String(text || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const { marker, label, why } of FORBIDDEN) {
const hit = marker instanceof RegExp ? marker.test(line) : line.includes(marker);
if (hit) {
findings.push({
marker: label || String(marker),
line: i + 1,
excerpt: line.trim().slice(0, 120),
why,
});
}
}
}
return { clean: findings.length === 0, findings };
}
/** Convenience wrapper for CLI callers: read + scan, tolerating a missing file. */
export function verifyAcceptedFile(fs, filePath) {
let text;
try {
text = fs.readFileSync(filePath, 'utf-8');
} catch {
return { clean: true, findings: [], missing: true };
}
return { ...verifyAcceptedSource(text), missing: false };
}

View File

@@ -0,0 +1,77 @@
import fs from 'node:fs';
import path from 'node:path';
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) {
if (!scriptsDir) throw new Error('scriptsDir is required');
return parts.map((part, index) => ({
...part,
index,
path: path.join(scriptsDir, part.file),
}));
}
export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) {
for (const part of parts) {
if (!exists(part.path)) {
throw new Error(`Live browser script part missing: ${part.name} (${part.path})`);
}
}
return parts;
}
export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) {
return parts.map((part) => ({
...part,
source: readFile(part.path),
}));
}
export function assembleLiveBrowserScript({
token,
port,
vocabulary,
commandPrefix = '/',
appRoot = null,
parts,
// Defaulted rather than threaded through live-server.mjs: the browser bundle
// must always carry the canonical inventory, and a default makes that true by
// construction instead of by every caller remembering to pass it. Overridable
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
// Project identity for browser-side session storage. localStorage is
// keyed by ORIGIN, and two projects routinely share a localhost port
// across time; saved sessions carry this value so a resume can tell a
// foreign project's leftovers from its own.
`window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` +
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
// is a classic script and cannot import an ES module at runtime, so the list
// is serialized here and read off the global there. Node consumers (this
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`;
}).join('\n');
return prelude + body;
}

View File

@@ -0,0 +1,28 @@
// A preview whose variants live in component modules rather than in the user's
// source. These leave no markers in the real file, so a failed accept gives the
// agent nothing to hand-edit and must be reported as a failure rather than
// reference/live.md's manual-cleanup handoff. Kept as a set: any future
// component-module preview mode belongs here the day it lands.
const PREVIEW_MODES_WITHOUT_SOURCE_MARKERS = new Set([
'svelte-component',
]);
export function completionTypeForAcceptResult(eventType, acceptResult) {
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && PREVIEW_MODES_WITHOUT_SOURCE_MARKERS.has(acceptResult?.previewMode)) return 'error';
return 'agent_done';
}
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
const ack = { ok: true, type: completionType };
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
ack.final = false;
ack.requiresComplete = true;
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
}
return ack;
}

View File

@@ -0,0 +1,199 @@
/**
* Shared event validation for the live helper server.
* Extracted for unit testing (insert mode rules).
*/
import { canCreateInsert } from './insert-ui.mjs';
// The accepted protocol values come from the canonical vocabulary so the
// validator, the store, the server, and the picker UI never drift. Imported
// (not just re-exported) so they are also in scope for the validators below.
import { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS } from './vocabulary.mjs';
export { AGENT_PHASES, CLIENT_EVENT_TYPES, VISUAL_ACTIONS };
const AGENT_PHASE_SET = new Set(AGENT_PHASES);
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
const INSERT_POSITIONS = new Set(['before', 'after']);
const FORBIDDEN_MANUAL_EDIT_TEXT_CHARS = ['<', '{', '}', '`'];
// Mount acknowledgements carry a module URL and a raw exception message from
// the page. Both are attacker-adjacent (any script on the page can POST them
// with the token it can already read), so they are length-capped before they
// reach the journal.
export const MOUNT_URL_MAX_LENGTH = 2000;
export const MOUNT_ERROR_MAX_LENGTH = 1000;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateManualEditText(newText) {
if (typeof newText !== 'string') return null;
const hits = FORBIDDEN_MANUAL_EDIT_TEXT_CHARS.filter((char) => newText.includes(char));
return hits.length > 0 ? hits : null;
}
function validateAnnotationFields(msg) {
if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') {
return 'generate: screenshotPath must be string';
}
if (msg.comments !== undefined && !Array.isArray(msg.comments)) {
return 'generate: comments must be array';
}
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) {
return 'generate: strokes must be array';
}
return null;
}
function validateInsertGenerate(msg) {
if (!msg.insert || typeof msg.insert !== 'object') return 'generate: insert mode requires insert object';
if (!INSERT_POSITIONS.has(msg.insert.position)) return 'generate: insert.position must be before or after';
const anchor = msg.insert.anchor;
if (!anchor || typeof anchor !== 'object') return 'generate: insert.anchor required';
if (!anchor.tagName && !anchor.outerHTML && !(Array.isArray(anchor.classes) && anchor.classes.length)) {
return 'generate: insert.anchor needs tagName, classes, or outerHTML';
}
if (!msg.placeholder || typeof msg.placeholder !== 'object') return 'generate: insert mode requires placeholder dimensions';
if (!Number.isFinite(msg.placeholder.width) || !Number.isFinite(msg.placeholder.height)) {
return 'generate: placeholder width and height must be numbers';
}
if (!canCreateInsert({
prompt: msg.freeformPrompt,
comments: msg.comments,
strokes: msg.strokes,
})) {
return 'generate: insert requires freeformPrompt or annotations';
}
return validateAnnotationFields(msg);
}
function validateReplaceGenerate(msg) {
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return validateAnnotationFields(msg);
}
function validateManualEditEvent(msg, label) {
if (!isValidId(msg.id)) return label + ': missing or malformed id';
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return label + ': missing pageUrl';
if (!msg.element || typeof msg.element !== 'object') return label + ': missing element';
if (!Array.isArray(msg.ops) || msg.ops.length === 0) return label + ': ops must be non-empty array';
if (msg.ops.length > 100) return label + ': too many ops (max 100)';
for (const op of msg.ops) {
if (typeof op.ref !== 'string') return label + ': op.ref required';
if (typeof op.tag !== 'string') return label + ': op.tag required';
if (typeof op.originalText !== 'string') return label + ': op.originalText required';
if (op.deleted !== true && typeof op.newText !== 'string') {
return label + ': text op requires newText';
}
if (typeof op.newText === 'string') {
if (op.deleted !== true && op.newText.trim().length === 0) {
return label + ': newText cannot be empty';
}
const forbidden = validateManualEditText(op.newText);
if (forbidden) {
return label + ': newText cannot contain ' + forbidden.join(' ') + ' (plain text only; ask the AI to insert markup)';
}
}
}
return null;
}
function isValidMountVariant(value) {
return Number.isInteger(value) && value >= 1 && value <= 999;
}
/**
* Mount acknowledgements are the browser's answer to "did the thing you
* published actually render". They are validated strictly because the render
* truth in the session snapshot is built from them: a malformed ack that slid
* through would report a variant as mounted that never was.
*/
function validateMountAck(msg) {
if (!isValidId(msg.id)) return 'variant_mounted: missing or malformed id';
if (!isValidMountVariant(msg.variant)) return 'variant_mounted: variant must be an integer 1-999';
if (msg.url !== undefined) {
if (typeof msg.url !== 'string') return 'variant_mounted: url must be string';
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mounted: url too long';
}
return null;
}
function validateMountFailure(msg) {
if (!isValidId(msg.id)) return 'variant_mount_failed: missing or malformed id';
if (!isValidMountVariant(msg.variant)) return 'variant_mount_failed: variant must be an integer 1-999';
if (typeof msg.url !== 'string' || !msg.url.trim()) return 'variant_mount_failed: url required';
if (msg.url.length > MOUNT_URL_MAX_LENGTH) return 'variant_mount_failed: url too long';
if (typeof msg.error !== 'string' || !msg.error.trim()) return 'variant_mount_failed: error required';
if (msg.error.length > MOUNT_ERROR_MAX_LENGTH) return 'variant_mount_failed: error too long';
return null;
}
export function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (msg.mode === 'insert') return validateInsertGenerate(msg);
return validateReplaceGenerate(msg);
case 'accept':
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
}
}
return null;
case 'discard':
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'checkpoint':
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
return 'checkpoint: paramValues must be an object';
}
return null;
case 'agent_phase':
if (!isValidId(msg.id)) return 'agent_phase: missing or malformed id';
if (typeof msg.phase !== 'string' || !msg.phase) return 'agent_phase: missing phase';
// The enum, not a shape pattern. A phase the browser cannot rank is a
// phase the progress bar cannot show, so accepting an arbitrary
// lowercase word only defers the failure to the UI.
if (!AGENT_PHASE_SET.has(msg.phase)) {
return 'agent_phase: unknown phase ' + msg.phase + ' (expected one of ' + AGENT_PHASES.join(', ') + ')';
}
if (msg.durationMs !== undefined && (!Number.isFinite(msg.durationMs) || msg.durationMs < 0)) {
return 'agent_phase: durationMs must be a non-negative number';
}
return null;
case 'variant_mounted':
return validateMountAck(msg);
case 'variant_mount_failed':
return validateMountFailure(msg);
case 'exit':
return null;
case 'prefetch':
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
return null;
case 'manual_edits':
return validateManualEditEvent(msg, 'manual_edits');
case 'steer':
if (!isValidId(msg.id)) return 'steer: missing or malformed id';
if (typeof msg.message !== 'string' || !msg.message.trim()) return 'steer: message required';
if (msg.message.length > 4000) return 'steer: message too long';
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
return null;
case 'carbonize_cleanup':
if (!isValidId(msg.id)) return 'carbonize_cleanup: missing or malformed id';
if (!isValidId(msg.sessionId)) return 'carbonize_cleanup: missing or malformed sessionId';
if (!msg.file || typeof msg.file !== 'string') return 'carbonize_cleanup: missing file';
if (!isValidVariantId(String(msg.variantId))) return 'carbonize_cleanup: missing or malformed variantId';
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}

View File

@@ -0,0 +1,47 @@
/**
* Astro registry entry.
*
* Astro takes the generic tag strategy, with two Astro-specific values that
* used to sit as inline `endsWith('.astro')` branches in live-inject.mjs and
* live-wrap.mjs:
*
* injectScriptAttrs Astro processes <script> tags by default and rewrites
* src to its own bundled URL; is:inline opts out.
* styleMode Astro scopes component styles, which strips preview CSS
* off the generated variant wrappers, so preview rules are
* authored global and prefixed instead of @scope'd.
*/
import { findConfigFile, hasAnyDependency, literalConfigFiles } from './detect-utils.mjs';
const ASTRO_CONFIG_RE = /^astro\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectAstroProject(cwd = process.cwd(), config = null) {
const configFile = findConfigFile(cwd, ASTRO_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['astro'])) return { configFile: null, via: 'package' };
// A tree of .astro entry templates with no astro.config still belongs to
// Astro; the configured injection target names it.
const entry = literalConfigFiles(cwd, config).find((rel) => rel.endsWith('.astro'));
if (entry) return { configFile: null, via: 'config-files', entry };
return null;
}
export const astro = {
name: 'astro',
detect(cwd, config) {
return detectAstroProject(cwd, config);
},
inject: { kind: 'tag' },
source: {
extensions: ['.astro'],
preview: 'source',
styleMode: 'astro-global-prefixed',
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
commentSyntax: 'html',
injectScriptAttrs: 'is:inline ',
},
};

View File

@@ -0,0 +1,73 @@
/**
* Small read-only probes the framework entries share.
*
* Every helper here is cheap and failure-tolerant: detection runs on every
* inject, against project trees that may be half-installed, so a missing or
* malformed file means "not this framework", never a throw.
*/
import fs from 'node:fs';
import path from 'node:path';
/** Merged dependency names from package.json, or an empty object. */
export function readPackageDeps(cwd) {
const file = path.join(cwd, 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
return {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
} catch {
return {};
}
}
export function hasAnyDependency(cwd, names) {
const deps = readPackageDeps(cwd);
return names.some((name) => Boolean(deps[name]));
}
/** First top-level file name matching `re`, or null. */
export function findConfigFile(cwd, re) {
try {
return fs.readdirSync(cwd, { withFileTypes: true })
.find((entry) => entry.isFile() && re.test(entry.name))
?.name ?? null;
} catch {
return null;
}
}
export function fileExists(cwd, rel) {
try {
return fs.existsSync(path.join(cwd, rel));
} catch {
return false;
}
}
export function firstExistingFile(cwd, candidates) {
for (const rel of candidates) {
if (fileExists(cwd, rel)) return rel;
}
return null;
}
/**
* Literal (non-glob) entries of `config.files` that exist on disk. Several
* detectors read the configured injection target as a signal, which is how the
* bare fixtures — a tree of `.astro` files with no astro.config — still resolve
* to the framework that authored them.
*/
export function literalConfigFiles(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : [];
const out = [];
for (const rel of files) {
if (typeof rel !== 'string' || rel.includes('*') || rel.includes('?')) continue;
const normalized = rel.split(path.sep).join('/');
if (fileExists(cwd, normalized)) out.push(normalized);
}
return out;
}

View File

@@ -0,0 +1,143 @@
/**
* The live-mode framework registry.
*
* Before this existed, framework knowledge was smeared across live-inject.mjs
* (detection order, the Nuxt adapter, the Astro `is:inline` branch), the two
* adapter modules, and live-wrap.mjs (which extension gets component preview,
* which gets Astro's global-prefixed CSS, which gets JSX comments). Adding or
* fixing a framework meant reading all of them.
*
* One entry per framework now declares everything the live scripts need:
*
* name stable identifier; also the `adapter` value in inject JSON.
* detect (cwd, config) → falsy when this is not the project, otherwise
* a truthy project descriptor that apply/remove/artifacts read.
* Order in FRAMEWORKS is priority order; first truthy wins.
* inject { kind: 'adapter', apply, remove, ignorePatterns, artifacts,
* unpatch } for frameworks that server-render their document
* shell, or { kind: 'tag' } for the generic marker-wrapped
* <script src> block.
* source how live-wrap treats files this framework authors:
* extensions, preview ('source' | 'component'), styleMode,
* styleTag, commentSyntax, injectScriptAttrs. Anything omitted
* falls back to SOURCE_TRAIT_DEFAULTS.
*
* Two rules hold the thing together:
*
* 1. **Detection order is injection priority.** SvelteKit → Nuxt → TanStack
* Start → Astro → Next → Vite → static HTML, exactly the order
* live-inject.mjs used to hard-code. static-html always matches, so
* resolveFramework never returns null.
* 2. **Source traits resolve by file extension, not by project.** A SvelteKit
* project's injection target is `src/app.html`; a Vite app can contain
* `.astro` partials. live-wrap has always keyed these off the target file,
* and resolveSourceTraits keeps it that way. Several entries may claim the
* same extension (`.tsx` belongs to three); when they do, the values must
* agree, which tests/live-frameworks.test.mjs asserts.
*/
import path from 'node:path';
import { sveltekit } from './sveltekit.mjs';
import { nuxt } from './nuxt.mjs';
import { tanstackStart } from './tanstack-start.mjs';
import { astro } from './astro.mjs';
import { nextjs } from './nextjs.mjs';
import { viteGeneric } from './vite-generic.mjs';
import { staticHtml } from './static-html.mjs';
import { TAG_PATCH_MARKERS, unpatchTagFile } from './tag-strategy.mjs';
/** Priority order. Do not reorder without re-reading rule 1 above. */
export const FRAMEWORKS = Object.freeze([
sveltekit,
nuxt,
tanstackStart,
astro,
nextjs,
viteGeneric,
staticHtml,
]);
export const PREVIEW_MODES = Object.freeze(['source', 'component']);
export const STYLE_MODES = Object.freeze(['scoped', 'astro-global-prefixed']);
export const COMMENT_SYNTAXES = Object.freeze(['html', 'jsx']);
export const INJECT_KINDS = Object.freeze(['adapter', 'tag']);
export const SOURCE_TRAIT_DEFAULTS = Object.freeze({
preview: 'source',
styleMode: 'scoped',
styleTag: '<style data-impeccable-css="SESSION_ID">',
commentSyntax: 'html',
injectScriptAttrs: '',
});
/** The patch kind the generic tag strategy records in the journal. */
export const TAG_PATCH_KIND = 'live-tag';
/**
* Undo functions keyed by the `patch` value an artifact carries. Built from
* the entries so a new adapter registers its own undo alongside its apply.
*/
export const PATCH_UNDOERS = Object.freeze(Object.assign(
{ [TAG_PATCH_KIND]: unpatchTagFile },
...FRAMEWORKS.map((framework) => framework.inject.unpatch || {}),
));
/**
* First entry whose detect() matches. Returns { framework, project } where
* project is the detector's descriptor (adapters read it; tag frameworks
* mostly ignore it).
*/
export function resolveFramework(cwd = process.cwd(), config = null) {
for (const framework of FRAMEWORKS) {
const project = framework.detect(cwd, config);
if (project) return { framework, project };
}
// Unreachable while static-html stays terminal, but a caller that reorders
// the array should get a diagnosable null rather than a silent tag inject.
return null;
}
/**
* Source-authoring traits for one file, merged over SOURCE_TRAIT_DEFAULTS.
* `framework` names the entry that claimed the extension, or null.
*/
export function resolveSourceTraits(filePath) {
const ext = path.extname(String(filePath || '')).toLowerCase();
for (const framework of FRAMEWORKS) {
const source = framework.source;
if (!source || !source.extensions.includes(ext)) continue;
const { extensions, ...traits } = source;
return { framework: framework.name, ...SOURCE_TRAIT_DEFAULTS, ...traits };
}
return { framework: null, ...SOURCE_TRAIT_DEFAULTS };
}
/**
* Extra gitignore patterns the resolved framework needs beyond the static
* LIVE_IGNORE_PATTERNS list (paths that depend on a detected srcDir or file
* extension and so cannot be written down ahead of time).
*/
export function frameworkIgnorePatterns(resolved) {
const fn = resolved?.framework?.inject?.ignorePatterns;
return typeof fn === 'function' ? (fn(resolved.project) || []) : [];
}
/**
* The files this injection will create or patch, in journal-artifact form.
* Adapters declare their own; the tag strategy patches exactly the resolved
* config files.
*/
export function describeInjectArtifacts(resolved, { cwd = process.cwd(), files = [] } = {}) {
if (!resolved) return [];
const { framework, project } = resolved;
if (framework.inject.kind === 'adapter') {
return (framework.inject.artifacts?.({ cwd, project }) || []).filter((a) => a && a.path);
}
return files.map((file) => ({
kind: 'patched',
path: file,
patch: TAG_PATCH_KIND,
markers: [...TAG_PATCH_MARKERS],
}));
}

View File

@@ -0,0 +1,197 @@
/**
* Crash-safe injection journal.
*
* Injection writes into the user's source tree: generated components, a Nuxt
* client plugin, marker blocks inside a layout, a patched CSP meta tag. The
* clean path removes all of it on stop. The unclean paths do not:
*
* - the dev server is SIGKILLed, so `--remove` never runs;
* - the project changes shape between start and stop (a nuxt.config appears,
* a package.json is edited), so detection resolves a different framework
* and the old framework's artifacts are nobody's business;
* - stop runs from a different directory than start did.
*
* So every inject records what it wrote to `.impeccable/live/inject-journal.json`
* before the next one runs, and both inject and `--remove` reconcile that
* record against the tree.
*
* **The journal is a claim of ownership, not a to-do list.** Healing an
* artifact only ever removes what still carries our marker; a generated file
* the user has since replaced, or a layout they have since un-patched by hand,
* is dropped from the journal untouched.
*
* **Path resolution is appRoot-relative.** Live entry scripts chdir onto the
* roots manifest (`enterLiveRoot`) before doing anything, so a journal written
* by a session started in the app root is found by a stop issued from any
* directory inside the repo.
*/
import fs from 'node:fs';
import path from 'node:path';
import { PATCH_UNDOERS } from './index.mjs';
export const INJECT_JOURNAL_VERSION = 1;
export const INJECT_JOURNAL_RELPATH = '.impeccable/live/inject-journal.json';
export function injectJournalPath(cwd = process.cwd()) {
return path.join(cwd, ...INJECT_JOURNAL_RELPATH.split('/'));
}
export function readInjectJournal(cwd = process.cwd()) {
const file = injectJournalPath(cwd);
let raw;
try {
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.artifacts)) return null;
return raw;
}
export function clearInjectJournal(cwd = process.cwd()) {
try { fs.unlinkSync(injectJournalPath(cwd)); } catch { /* already gone */ }
}
function writeInjectJournal(cwd, journal) {
const file = injectJournalPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
return file;
}
/**
* Record the artifacts an injection just wrote. Replaces any previous record:
* callers heal first (see healInjectJournal), so nothing survivable is lost.
*/
export function recordInjection(cwd = process.cwd(), { framework, port, artifacts = [] } = {}) {
if (!artifacts.length) {
clearInjectJournal(cwd);
return null;
}
return writeInjectJournal(cwd, {
version: INJECT_JOURNAL_VERSION,
appRoot: path.resolve(cwd),
framework: framework || null,
port: Number.isFinite(Number(port)) ? Number(port) : null,
pid: process.pid,
recordedAt: new Date().toISOString(),
artifacts,
});
}
function normalizeRel(cwd, rel) {
return path.resolve(cwd, String(rel || '')).split(path.sep).join('/');
}
function readIfPresent(abs) {
try {
return fs.readFileSync(abs, 'utf-8');
} catch {
return null;
}
}
function pruneEmptyDirs(dir, stopDir) {
let current = path.resolve(dir);
const stop = path.resolve(stopDir);
while (current !== stop && current.startsWith(stop + path.sep)) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
} catch {
return;
}
current = path.dirname(current);
}
}
function insideProject(cwd, abs) {
const rel = path.relative(path.resolve(cwd), path.resolve(abs));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function healArtifact(cwd, artifact, undoers) {
const abs = path.resolve(cwd, artifact.path);
// The journal is a project-local file, i.e. attacker-writable input in a
// cloned repo. Never touch anything outside the project tree, whatever the
// journal claims to own.
if (!insideProject(cwd, abs)) return { path: artifact.path, action: 'refused_outside_project' };
const content = readIfPresent(abs);
if (content === null) return { path: artifact.path, action: 'absent' };
if (artifact.kind === 'created') {
// Only reclaim a generated file that still carries our marker; a created
// artifact with no marker at all is unverifiable and stays untouched.
if (!artifact.marker || !content.includes(artifact.marker)) {
return { path: artifact.path, action: 'disowned' };
}
try { fs.rmSync(abs, { force: true }); } catch { return null; }
if (artifact.pruneTo !== undefined) {
const pruneRoot = path.resolve(cwd, artifact.pruneTo || '.');
if (insideProject(cwd, pruneRoot) || pruneRoot === path.resolve(cwd)) {
pruneEmptyDirs(path.dirname(abs), pruneRoot);
}
}
return { path: artifact.path, action: 'removed' };
}
if (artifact.kind === 'patched') {
const markers = Array.isArray(artifact.markers) ? artifact.markers : [];
// No marker left means the patch is already gone; never run an undo over
// a file we no longer recognize (the undoers normalize whitespace).
if (markers.length && !markers.some((marker) => content.includes(marker))) {
return { path: artifact.path, action: 'disowned' };
}
const undo = undoers[artifact.patch];
if (typeof undo !== 'function') return null;
const next = undo(content);
if (next === content) return { path: artifact.path, action: 'disowned' };
try { fs.writeFileSync(abs, next, 'utf-8'); } catch { return null; }
return { path: artifact.path, action: 'unpatched' };
}
return null;
}
/**
* Reconcile the journal against the tree.
*
* `keep` is the set of paths the current operation legitimately owns — the
* artifacts an inject is about to (re)write. Everything else in the journal is
* an orphan of a session that is gone, and gets healed. This keeps a repeat
* inject byte-idempotent: the artifacts it is about to rewrite are kept, not
* torn down and rebuilt.
*
* Returns `{ healed, kept }`. `healed` lists only artifacts whose file was
* actually changed or removed, so callers can stay silent when nothing was
* orphaned. Idempotent: a second call finds an empty journal.
*/
export function healInjectJournal(cwd = process.cwd(), { keep = [], undoers = PATCH_UNDOERS } = {}) {
const journal = readInjectJournal(cwd);
if (!journal) return { healed: [], kept: [] };
const keepSet = new Set(keep.map((rel) => normalizeRel(cwd, rel)));
const healed = [];
const kept = [];
for (const artifact of journal.artifacts) {
if (!artifact || typeof artifact.path !== 'string') continue;
if (keepSet.has(normalizeRel(cwd, artifact.path))) {
kept.push(artifact);
continue;
}
const outcome = healArtifact(cwd, artifact, undoers);
if (outcome && (outcome.action === 'removed' || outcome.action === 'unpatched')) {
healed.push(outcome);
}
}
if (kept.length) {
writeInjectJournal(cwd, { ...journal, artifacts: kept });
} else {
clearInjectJournal(cwd);
}
return { healed, kept };
}

View File

@@ -0,0 +1,49 @@
/**
* Next.js registry entry.
*
* Next takes the generic tag strategy: the App Router's root layout renders
* `<html>…<body>` in JSX, so the marker-wrapped script block goes in there
* verbatim. Nothing about injection differs from a plain Vite app, which is
* why live-inject.mjs never had a Next branch. The entry exists so the
* registry can name what it is looking at.
*/
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
const NEXT_CONFIG_RE = /^next\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
const ROUTER_ENTRY_CANDIDATES = [
'app/layout.tsx', 'app/layout.jsx', 'app/layout.ts', 'app/layout.js',
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.ts', 'src/app/layout.js',
'pages/_app.tsx', 'pages/_app.jsx', 'pages/_app.ts', 'pages/_app.js',
'pages/_document.tsx', 'pages/_document.jsx',
'src/pages/_app.tsx', 'src/pages/_app.jsx',
];
export function detectNextProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, NEXT_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['next'])) return { configFile: null, via: 'package' };
// Next's file conventions are distinctive enough to stand alone: a root
// `app/layout.*` or `pages/_app.*` is not a shape other bundlers produce.
const entry = ROUTER_ENTRY_CANDIDATES.find((rel) => fileExists(cwd, rel));
if (entry) return { configFile: null, via: 'router-entry', entry };
return null;
}
export const nextjs = {
name: 'nextjs',
detect(cwd) {
return detectNextProject(cwd);
},
inject: { kind: 'tag' },
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};

View File

@@ -0,0 +1,161 @@
/**
* Nuxt registry entry, and the Nuxt adapter itself.
*
* A script element placed in app.vue is compiled as Vue-rendered DOM and is
* not executed. Nuxt instead auto-discovers client plugins. Keep the adapter
* generated, dev-only, and outside user-authored source: Live creates one
* marked .client.ts plugin on start and removes it on stop.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from './script-src.mjs';
import { findConfigFile } from './detect-utils.mjs';
export const NUXT_PLUGIN_MARKER = 'impeccable-live-nuxt-plugin';
export const NUXT_PLUGIN_NAME = 'impeccable-live.client.ts';
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectNuxtProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, NUXT_CONFIG_RE);
if (!configFile) return null;
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
const literalSrcDir = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
let appDir = '';
if (literalSrcDir) {
const candidate = literalSrcDir[2]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
const normalized = path.posix.normalize(candidate);
if (normalized !== '..' && !normalized.startsWith('../') && !path.isAbsolute(normalized)) {
appDir = normalized === '.' ? '' : normalized;
}
} else if (
fs.existsSync(path.join(cwd, 'app', 'app.vue'))
|| fs.existsSync(path.join(cwd, 'app', 'pages'))
) {
appDir = 'app';
}
const pluginFile = [appDir, 'plugins', NUXT_PLUGIN_NAME].filter(Boolean).join('/');
return { configFile, appDir, pluginFile };
}
export function buildNuxtPlugin(port, token) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = '${buildLiveScriptSrc(port, token)}';
const liveSelector = 'script[data-impeccable-live-nuxt]';
export default defineNuxtPlugin(() => {
if (!import.meta.dev || typeof document === 'undefined') return;
const expectedSrc = new URL(liveSrc, window.location.href).href;
let script = document.querySelector(liveSelector);
if (script?.src === expectedSrc) return;
script?.remove();
script = document.createElement('script');
script.src = liveSrc;
script.async = true;
script.dataset.impeccableLiveNuxt = '';
document.head.appendChild(script);
import.meta.hot?.dispose(() => {
if (script?.isConnected) script.remove();
});
});
/* /${NUXT_PLUGIN_MARKER} */
`;
}
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, token, project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
if (existing !== null && !existing.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} already exists and is not managed by Impeccable Live`,
};
}
const content = buildNuxtPlugin(port, token);
fs.mkdirSync(path.dirname(absFile), { recursive: true });
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
return {
file: project.pluginFile,
inserted: true,
changed: content !== existing,
devOnly: true,
};
}
export function removeNuxtLiveAdapter({ cwd = process.cwd(), project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
if (!fs.existsSync(absFile)) {
return { file: project.pluginFile, removed: false, note: 'no adapter present' };
}
const content = fs.readFileSync(absFile, 'utf-8');
if (!content.includes(NUXT_PLUGIN_MARKER)) {
return {
file: project.pluginFile,
removed: false,
error: 'nuxt_plugin_conflict',
hint: `${project.pluginFile} is not managed by Impeccable Live`,
};
}
fs.unlinkSync(absFile);
const pluginDir = path.dirname(absFile);
if (fs.readdirSync(pluginDir).length === 0) fs.rmdirSync(pluginDir);
return { file: project.pluginFile, removed: true };
}
export const nuxt = {
name: 'nuxt',
detect(cwd) {
return detectNuxtProject(cwd);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, project }) {
return applyNuxtLiveAdapter({ cwd, port, token, project });
},
remove({ cwd, project }) {
return removeNuxtLiveAdapter({ cwd, project });
},
// The plugin path depends on the resolved srcDir, so it cannot live in the
// static ignore list the way the SvelteKit paths do.
ignorePatterns(project) {
return project?.pluginFile ? [project.pluginFile] : [];
},
artifacts({ project }) {
if (!project?.pluginFile) return [];
return [{
kind: 'created',
path: project.pluginFile,
marker: NUXT_PLUGIN_MARKER,
// Mirrors removeNuxtLiveAdapter: the generated `plugins/` directory
// goes when it empties, its parent stays.
pruneTo: path.posix.dirname(path.posix.dirname(project.pluginFile)),
}];
},
},
source: {
extensions: ['.vue'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'html',
},
};

View File

@@ -0,0 +1,17 @@
/**
* The one place that builds the `/live.js` URL the browser loads.
*
* Every injection path needs it (the generic script tag, the Nuxt client
* plugin, the SvelteKit root component, the TanStack mount component), and a
* separate module keeps that shared leaf free of import cycles: the framework
* entries import it, and nothing here imports a framework entry.
*/
/**
* When a token is supplied it rides as a `?token=...` query param so the
* server's token-gated /live.js handler authorizes the fetch.
*/
export function buildLiveScriptSrc(port, token) {
const base = 'http://localhost:' + port + '/live.js';
return token ? base + '?token=' + encodeURIComponent(token) : base;
}

View File

@@ -0,0 +1,26 @@
/**
* Static HTML registry entry: the terminal fallback.
*
* Hand-written pages, a multi-page site emitted by a generator, anything with
* no bundler config at the app root. `detect` always matches, so this entry
* must stay last in FRAMEWORKS. Its behavior is the plain tag strategy, which
* is what live-inject.mjs did for every unrecognized project before the
* registry existed.
*/
export const staticHtml = {
name: 'static-html',
detect() {
return { via: 'fallback' };
},
inject: { kind: 'tag' },
source: {
extensions: ['.html', '.htm'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'html',
},
};

View File

@@ -0,0 +1,71 @@
/**
* SvelteKit registry entry.
*
* Detection and the apply/remove pair are the existing adapter's
* (`../sveltekit-adapter.mjs`); this file only declares them to the registry
* and names the artifacts the journal has to be able to heal.
*/
import {
SVELTE_LAYOUT_MARKER_OPEN,
SVELTE_LIVE_ROOT_COMPONENT,
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
unpatchSvelteLayout,
} from '../sveltekit-adapter.mjs';
export const sveltekit = {
name: 'sveltekit',
detect(cwd, config) {
return detectSvelteKitProject(cwd, config);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, config }) {
return applySvelteKitLiveAdapter({ cwd, port, token, config });
},
remove({ cwd, config }) {
return removeSvelteKitLiveAdapter({ cwd, config });
},
// The generated root component and the `src/lib/impeccable/` runtime paths
// are already in the static LIVE_IGNORE_PATTERNS list, so nothing extra.
ignorePatterns() {
return [];
},
artifacts({ project }) {
return [
{
kind: 'created',
path: SVELTE_LIVE_ROOT_COMPONENT,
marker: 'impeccable-live-root',
pruneTo: 'src',
},
{
kind: 'patched',
path: project?.layoutFile || 'src/routes/+layout.svelte',
patch: 'sveltekit-layout',
markers: [SVELTE_LAYOUT_MARKER_OPEN],
},
];
},
unpatch: {
'sveltekit-layout': unpatchSvelteLayout,
},
},
source: {
extensions: ['.svelte'],
// Svelte resets component-local state on markup HMR updates, so variants
// are mounted from generated components rather than written into the route.
preview: 'component',
commentSyntax: 'html',
},
};

View File

@@ -0,0 +1,247 @@
/**
* The generic `tag` injection strategy.
*
* Frameworks without a dedicated adapter get a literal marker-wrapped
* `<script src>` block written into the entry template named by
* `.impeccable/live/config.json`. This module owns that block: building it,
* inserting it at the configured anchor, removing it again, and the
* Content-Security-Policy meta patch that keeps the cross-origin load allowed.
*
* It is deliberately framework-agnostic. Per-framework knowledge (Astro's
* `is:inline`, for instance) arrives as the `scriptAttrs` argument, resolved
* from the registry by the caller, so nothing here has to branch on a file
* extension or a project shape.
*/
import { buildLiveScriptSrc } from './script-src.mjs';
export const MARKER_OPEN_TEXT = 'impeccable-live-start';
export const MARKER_CLOSE_TEXT = 'impeccable-live-end';
/** Markers that identify a file as still carrying our tag-strategy patch. */
export const TAG_PATCH_MARKERS = Object.freeze([MARKER_OPEN_TEXT, 'data-impeccable-csp-original']);
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
/**
* `scriptAttrs` is a pre-rendered attribute string (trailing space included)
* that the registry supplies for the target file. Astro is the only framework
* that uses it today: Astro processes `<script>` tags by default and rewrites
* src to its own bundled URL, so `is:inline ` opts out and the literal external
* src survives.
*/
export function buildTagBlock(syntax, port, token, scriptAttrs = '') {
const open = commentOpen(syntax);
const close = commentClose(syntax);
return (
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
);
}
function detectLineEnding(content) {
if (content.includes('\r\n')) return '\r\n';
if (content.includes('\r')) return '\r';
return '\n';
}
function normalizeLineEndings(content, lineEnding) {
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
}
function readLineEndingAt(content, index) {
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
if (content[index] === '\n') return '\n';
if (content[index] === '\r') return '\r';
return '';
}
export function insertTag(content, config, port, token, scriptAttrs = '') {
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, token, scriptAttrs), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
if (config.insertBefore) {
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
// Preserve an existing trailing newline if the anchor already has one.
// Slice the remainder from the original anchor offset, not prefix.length:
// in the no-newline case prefix is one char longer than the anchor (the
// appended '\n'), so slicing by prefix.length would drop the first real
// character after the anchor (#227).
const existingNewline = readLineEndingAt(content, after);
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
const rest = content.slice(after + existingNewline.length);
return prefix + block + rest;
}
/**
* Remove the live script block. Matches either HTML or JSX comment markers
* regardless of config (so stale tags from a wrong config can still be cleaned).
*
* Indent-preserving: captures any whitespace immediately preceding the opener
* marker and re-emits it in place of the removed block. `insertTag` inserted
* the block *after* the original line's indent and *before* the anchor (e.g.
* `</body>`), which moved the indent onto the opener line and left the anchor
* unindented. Replacing the whole block (plus its trailing newline) with just
* the captured indent hands the indent back to the anchor that follows.
*/
export function removeTag(content, _syntax) {
const patterns = [
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
];
for (const pat of patterns) {
let changed = false;
let next = content;
do {
content = next;
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
if (/[\r\n]/.test(trailing)) return leadingIndent;
return leadingIndent || trailing || '';
});
if (next !== content) changed = true;
} while (next !== content);
if (changed) return next;
}
return content;
}
// ---------------------------------------------------------------------------
// Content-Security-Policy meta-tag patcher
//
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
// the cross-origin load of /live.js (and the SSE/POST connection back to
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
//
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
// and stash the original `content` value in a `data-impeccable-csp-original`
// attribute (base64) so revert is exact.
//
// On remove: detect the marker attribute, decode it, restore the original
// content value verbatim, drop the marker.
//
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
// shared helpers) is NOT patched here — those need framework-specific config
// edits and are handled via the existing detect-csp.mjs reference output.
// Only the in-source meta-tag form gets the auto-patch.
// ---------------------------------------------------------------------------
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
function findCspMetaTags(content) {
const out = [];
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
let m;
while ((m = tagRe.exec(content)) !== null) {
const attrs = m[1];
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
}
return out;
}
function getAttr(attrs, name) {
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
const m = attrs.match(re);
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
}
function appendOriginToDirective(csp, directive, origin) {
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
const m = csp.match(re);
if (m) {
const tokens = m[4].trim().split(/\s+/);
if (tokens.includes(origin)) return csp;
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
}
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
// narrow the policy compared to the default-src fallback (most users with
// an explicit CSP have 'self' there).
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
}
export function patchCspMeta(content, port) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
const origin = `http://localhost:${port}`;
// Walk last-to-first so prior splices don't invalidate later indices.
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const attrs = tag.attrs;
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
const contentAttr = getAttr(attrs, 'content');
if (!contentAttr) continue;
const original = contentAttr.value;
let patched = original;
patched = appendOriginToDirective(patched, 'script-src', origin);
patched = appendOriginToDirective(patched, 'connect-src', origin);
// The shader overlay during 'generating' creates a screenshot via
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
if (patched === original) continue;
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
export function revertCspMeta(content) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
if (!origAttr) continue;
const contentAttr = getAttr(tag.attrs, 'content');
if (!contentAttr) continue;
let originalValue;
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
catch { continue; }
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
// Drop the marker attribute and any single space immediately preceding it.
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
const newTag = tag.full.replace(tag.attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
/** The journal's undo for a tag-strategy patch: drop the block, restore CSP. */
export function unpatchTagFile(content) {
return revertCspMeta(removeTag(content));
}

View File

@@ -0,0 +1,70 @@
/**
* TanStack Start registry entry.
*
* Detection and the apply/remove pair are the existing adapter's
* (`../tanstack-adapter.mjs`); this file only declares them to the registry
* and names the artifacts the journal has to be able to heal.
*/
import {
TANSTACK_MARKER_OPEN,
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
unpatchTanStackRoot,
} from '../tanstack-adapter.mjs';
export const tanstackStart = {
name: 'tanstack-start',
detect(cwd) {
return detectTanStackStartProject(cwd);
},
inject: {
kind: 'adapter',
apply({ cwd, port, token, project }) {
return applyTanStackLiveAdapter({ cwd, port, token, project });
},
remove({ cwd, project }) {
return removeTanStackLiveAdapter({ cwd, project });
},
// The mount component's extension follows the root route's, so the path
// cannot live in the static ignore list.
ignorePatterns(project) {
return project?.componentFile ? [project.componentFile] : [];
},
artifacts({ project }) {
if (!project) return [];
return [
{
kind: 'created',
path: project.componentFile,
marker: 'impeccable-live-tanstack',
pruneTo: 'src',
},
{
kind: 'patched',
path: project.rootRoute,
patch: 'tanstack-root',
markers: [TANSTACK_MARKER_OPEN],
},
];
},
unpatch: {
'tanstack-root': unpatchTanStackRoot,
},
},
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};

View File

@@ -0,0 +1,42 @@
/**
* Generic Vite registry entry: a bundled app with a real `index.html` entry
* and no framework-specific document ownership. React, Vue, Solid, Preact and
* a plain TanStack Router SPA all land here — the marker-wrapped script block
* goes straight into the HTML entry.
*
* This is the entry that catches everything with a bundler config; only
* static-html sits below it.
*/
import { fileExists, findConfigFile, hasAnyDependency } from './detect-utils.mjs';
const VITE_CONFIG_RE = /^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
export function detectViteProject(cwd = process.cwd()) {
const configFile = findConfigFile(cwd, VITE_CONFIG_RE);
if (configFile) return { configFile, via: 'config' };
if (hasAnyDependency(cwd, ['vite'])) return { configFile: null, via: 'package' };
// A zero-config Vite app is index.html + package.json, the same pair
// roots.mjs treats as an app root.
if (fileExists(cwd, 'index.html') && fileExists(cwd, 'package.json')) {
return { configFile: null, via: 'zero-config' };
}
return null;
}
export const viteGeneric = {
name: 'vite-generic',
detect(cwd) {
return detectViteProject(cwd);
},
inject: { kind: 'tag' },
source: {
extensions: ['.tsx', '.jsx'],
preview: 'source',
styleMode: 'scoped',
commentSyntax: 'jsx',
},
};

View File

@@ -0,0 +1,149 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
// 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';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
if (!target.elementId && !target.classes) return null;
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);
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 };
}
/**
* Scaffold the source for a generate event before handing it to an agent.
*
* Async on purpose. This spawns `live-wrap.mjs`, which walks the project's
* source tree and can take seconds (measured at ~7.6s on a large repo when the
* element is not found, with a 15s ceiling). The live server is single-threaded
* and calls this while leasing a poll, so a synchronous spawn froze the whole
* server for that entire window: Accept and Discard POSTs, SSE progress
* broadcasts, and every other poll stalled behind it.
*/
export async function runGenerationPreflight(event, {
cwd = process.cwd(),
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
const startedAt = performance.now();
try {
const { stdout } = await execFileImpl(process.execPath, command.args, {
cwd,
encoding: 'utf-8',
timeout: timeoutMs,
});
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,
};
} 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,
durationMs: performance.now() - startedAt,
error: compactError(error),
};
}
}
function replaceTarget(event) {
return normalizeTarget(event.element || {});
}
function insertTarget(event) {
return {
...normalizeTarget(event.insert?.anchor || {}),
position: event.insert?.position === 'before' ? 'before' : 'after',
};
}
function normalizeTarget(target) {
const classes = Array.isArray(target.classes)
? target.classes.join(' ')
: String(target.classes || '').trim();
const text = typeof target.textContent === 'string'
? target.textContent.trim().slice(0, 80)
: '';
return {
elementId: target.id || target.elementId || undefined,
classes: classes || undefined,
tag: target.tagName || target.tag || undefined,
text: text || undefined,
};
}
function compactError(error) {
const stderr = error?.stderr ? String(error.stderr).trim() : '';
const message = stderr.split('\n').filter(Boolean).pop() || error?.message || 'preflight failed';
return String(message).slice(0, 500);
}

View File

@@ -0,0 +1,458 @@
/**
* Pure helpers for live-mode insert UI (browser + tests).
* Kept separate from live-browser.js so insert logic is unit-testable.
*/
export const PLACEHOLDER_DEFAULT_HEIGHT = 80;
export const PLACEHOLDER_MIN_HEIGHT = 48;
export const PLACEHOLDER_MIN_WIDTH = 120;
/** @typedef {'before' | 'after'} InsertPosition */
/** @typedef {'row' | 'column'} InsertAxis */
/**
* Infer sibling flow axis from a container's computed layout styles.
* @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style
* @returns {InsertAxis}
*/
export function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
if (display.includes('flex')) {
const dir = style.flexDirection || 'row';
return dir.startsWith('row') ? 'row' : 'column';
}
if (display === 'grid' || display === 'inline-grid') {
const flow = style.gridAutoFlow || 'row';
if (flow.includes('column')) return 'column';
const cols = (style.gridTemplateColumns || '').trim();
if (cols && cols !== 'none') {
const colCount = cols.split(/\s+/).filter(Boolean).length;
if (colCount > 1) return 'row';
}
return 'row';
}
return 'column';
}
/**
* Pick insertion side from pointer position against an anchor element box.
* @param {number} clientX
* @param {number} clientY
* @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
* @param {InsertAxis} [axis]
* @returns {InsertPosition}
*/
export function computeInsertPosition(clientX, clientY, rect, axis = 'column') {
if (!rect) return 'after';
if (axis === 'row') {
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after';
const mid = rect.left + rect.width / 2;
return clientX < mid ? 'before' : 'after';
}
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after';
const mid = rect.top + rect.height / 2;
return clientY < mid ? 'before' : 'after';
}
/**
* Whether Create is allowed for an insert session.
* Requires a non-empty prompt OR at least one annotation.
*/
export function canCreateInsert({ prompt, comments, strokes }) {
const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
const hasComments = Array.isArray(comments) && comments.length > 0;
const hasStrokes = Array.isArray(strokes) && strokes.some(
(s) => Array.isArray(s?.points) && s.points.length >= 2,
);
return hasPrompt || hasComments || hasStrokes;
}
/** Tooltip/title when Create is disabled. */
export function insertCreateDisabledReason({ prompt, comments, strokes }) {
if (canCreateInsert({ prompt, comments, strokes })) return null;
return 'Add a prompt or annotate the placeholder to create';
}
/**
* Fixed-position insert line coordinates (viewport px).
* @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
* @param {InsertPosition} position
* @param {InsertAxis} [axis]
*/
export function insertLineCoords(rect, position, axis = 'column') {
if (axis === 'row') {
const right = rect.right ?? rect.left + rect.width;
const x = position === 'before' ? rect.left - 2 : right + 2;
return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
}
const bottom = rect.bottom ?? rect.top + rect.height;
const y = position === 'before' ? rect.top - 2 : bottom + 2;
return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
}
/** Cursor while hovering an insert boundary. */
export function cursorForInsertAxis(axis) {
return axis === 'row' ? 'ew-resize' : 'ns-resize';
}
function groupSiblingRows(siblings, rowThreshold = 8) {
const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
const rows = [];
for (const entry of sorted) {
let placed = false;
for (const row of rows) {
if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
row.push(entry);
placed = true;
break;
}
}
if (!placed) rows.push([entry]);
}
return rows;
}
function horizontalOverlap(a, b) {
const left = Math.max(a.left, b.left);
const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width);
return Math.max(0, right - left);
}
/**
* Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks).
* @param {number} clientX
* @param {number} clientY
* @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings
* @param {{ slop?: number, minOverlap?: number }} [opts]
*/
export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) {
if (!Array.isArray(siblings) || siblings.length < 2) return null;
const slop = opts.slop ?? 12;
const minOverlap = opts.minOverlap ?? 0.25;
for (const row of groupSiblingRows(siblings)) {
if (row.length < 2) continue;
const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
const aRight = a.rect.right ?? a.rect.left + a.rect.width;
const bLeft = b.rect.left;
if (bLeft <= aRight) continue;
const top = Math.max(a.rect.top, b.rect.top);
const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height;
const bottom = Math.min(aBottom, bBottom);
const span = bottom - top;
const minH = Math.min(a.rect.height, b.rect.height);
if (span < minH * minOverlap) continue;
const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
const inY = clientY >= top - slop && clientY <= bottom + slop;
if (!inX || !inY) continue;
const midX = (aRight + bLeft) / 2;
return {
anchor: b.el,
position: 'before',
axis: 'row',
line: { axis: 'row', left: midX, top, width: 0, height: span },
};
}
}
const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
for (let i = 0; i < sortedCol.length - 1; i++) {
const a = sortedCol[i];
const b = sortedCol[i + 1];
const overlap = horizontalOverlap(a.rect, b.rect);
const minW = Math.min(a.rect.width, b.rect.width);
if (overlap < minW * minOverlap) continue;
const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
const gapTop = aBottom;
const gapBottom = b.rect.top;
if (gapBottom <= gapTop) continue;
const overlapLeft = Math.max(a.rect.left, b.rect.left);
const overlapRight = Math.min(
a.rect.right ?? a.rect.left + a.rect.width,
b.rect.right ?? b.rect.left + b.rect.width,
);
const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
if (!inY || !inX) continue;
const midY = (gapTop + gapBottom) / 2;
return {
anchor: b.el,
position: 'before',
axis: 'column',
line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 },
};
}
return null;
}
/**
* Resolve insert hover target, side, axis, and indicator line for the pointer.
*/
export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
const gap = hitSiblingInsertGap(clientX, clientY, siblings);
if (gap) return gap;
const position = computeInsertPosition(clientX, clientY, rect, axis);
const line = insertLineCoords(rect, position, axis);
return { anchor: target, position, axis, line };
}
/**
* How the in-flow placeholder should participate in layout.
* Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px.
* @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }}
*/
export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
const display = parentDisplay || 'block';
const w = Number.isFinite(parentWidth) ? parentWidth : 0;
if (axis === 'row') {
if (display.includes('flex')) {
const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
? anchorFlex
: '1 1 0';
return { kind: 'flex', flex, minWidth: 0 };
}
if (display === 'grid' || display === 'inline-grid') {
return { kind: 'auto' };
}
}
if (w >= PLACEHOLDER_MIN_WIDTH) {
return { kind: 'percent' };
}
return {
kind: 'explicit',
width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
};
}
/** Width kinds that need materializing to px before edge-resize. */
export function placeholderWidthIsImplicit(kind) {
return kind === 'flex' || kind === 'percent' || kind === 'auto';
}
/**
* Clamp user-resized placeholder dimensions.
*/
export function clampPlaceholderSize(width, height, parentWidth, opts = {}) {
const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH;
const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT;
const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW);
return {
width: Math.min(maxW, Math.max(minW, Math.round(width))),
height: Math.max(minH, Math.round(height)),
};
}
/** CSS cursor for a placeholder edge resize handle. */
export function cursorForPlaceholderEdge(edge) {
if (edge === 'n' || edge === 's') return 'ns-resize';
if (edge === 'e' || edge === 'w') return 'ew-resize';
return 'default';
}
/**
* Compute placeholder box after dragging one edge (in-flow margins shift for n/w).
* @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start
* @param {'n'|'e'|'s'|'w'} edge
* @param {number} dx pointer delta X since drag start
* @param {number} dy pointer delta Y since drag start
* @param {number} parentWidth
*/
export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) {
const base = {
width: start.width,
height: start.height,
marginLeft: start.marginLeft ?? 0,
marginTop: start.marginTop ?? 0,
};
if (edge === 'e') base.width = start.width + dx;
else if (edge === 'w') {
base.width = start.width - dx;
base.marginLeft = start.marginLeft + dx;
} else if (edge === 's') base.height = start.height + dy;
else if (edge === 'n') {
base.height = start.height - dy;
base.marginTop = start.marginTop + dy;
}
const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts);
if (edge === 'w') {
base.marginLeft = start.marginLeft + start.width - clamped.width;
} else if (edge === 'n') {
base.marginTop = start.marginTop + start.height - clamped.height;
}
return {
width: clamped.width,
height: clamped.height,
marginLeft: Math.round(base.marginLeft),
marginTop: Math.round(base.marginTop),
};
}
/** Pick and insert toggles are independent but turning one ON turns the other OFF. */
export function applyPickToggle(pickActive, insertActive) {
const nextPick = !pickActive;
return {
pickActive: nextPick,
insertActive: nextPick ? false : insertActive,
};
}
export function applyInsertToggle(pickActive, insertActive) {
const nextInsert = !insertActive;
return {
pickActive: nextInsert ? false : pickActive,
insertActive: nextInsert,
};
}
/**
* Build the browser generate payload for insert mode.
*/
export function buildInsertGeneratePayload({
id,
count,
pageUrl,
anchorContext,
position,
placeholder,
freeformPrompt,
comments,
strokes,
screenshotPath,
}) {
const payload = {
type: 'generate',
mode: 'insert',
id,
count,
pageUrl,
insert: {
position,
anchor: anchorContext,
},
placeholder,
freeformPrompt: freeformPrompt?.trim() || undefined,
};
if (comments?.length) payload.comments = comments;
if (strokes?.length) payload.strokes = strokes;
if (screenshotPath) payload.screenshotPath = screenshotPath;
return payload;
}
/**
* Whether a variant wrapper is currently shown (handles `hidden` and display:none).
* @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el
*/
export function isVariantShown(el) {
if (!el) return false;
if (el.hidden) return false;
if (el.style?.display === 'none') return false;
return true;
}
/**
* Show or hide a variant wrapper for cycling.
* @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el
* @param {boolean} shown
*/
export function setVariantShown(el, shown) {
if (!el) return;
if (shown) {
el.removeAttribute?.('hidden');
if (el.style) el.style.display = '';
} else {
el.setAttribute?.('hidden', '');
if (el.style) el.style.display = 'none';
}
}
/**
* Pick the best live anchor during an insert session (placeholder until variants land).
* @param {{
* wrapper?: unknown,
* variantCount?: number,
* visibleVariant?: number,
* placeholder?: unknown,
* insertAnchor?: unknown,
* pickVariantContent?: (wrapper: unknown, index: number) => unknown,
* }} opts
*/
export function resolveInsertSessionAnchor(opts) {
const {
wrapper,
variantCount = 0,
visibleVariant = 0,
placeholder,
insertAnchor,
pickVariantContent,
} = opts || {};
if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) {
const vis = pickVariantContent(wrapper, visibleVariant);
if (vis) return vis;
}
return placeholder || insertAnchor || null;
}
/**
* Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box.
* @param {{
* tagName?: string,
* className?: string,
* textContent?: string,
* }} anchor
* @param {{
* offsetWidth?: number,
* offsetHeight?: number,
* style?: { marginLeft?: string, marginTop?: string },
* }} placeholder
* @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta
*/
export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) {
return {
width: Math.round(placeholder.offsetWidth || 0),
height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0,
marginTop: parseFloat(placeholder.style?.marginTop || '') || 0,
position,
layoutAxis: layoutAxis || 'column',
anchorTag: anchor.tagName || 'DIV',
anchorClasses: anchor.className || '',
anchorText: (anchor.textContent || '').trim().slice(0, 120),
};
}
/**
* Re-find an insert anchor after framework HMR replaced the live DOM node.
* @param {Pick<Document, 'body' | 'querySelectorAll'>} doc
* @param {ReturnType<typeof buildInsertPlaceholderSnapshot> | null | undefined} snapshot
* @param {Element | null | undefined} liveAnchor
*/
export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) {
if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor;
if (!snapshot) return null;
const tag = (snapshot.anchorTag || 'div').toLowerCase();
const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
const needle = snapshot.anchorText || '';
const sel = cls ? `${tag}.${cls}` : tag;
const candidates = doc.querySelectorAll(sel);
for (const candidate of candidates) {
if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
return candidate;
}
return null;
}

View File

@@ -0,0 +1,142 @@
/**
* Just-in-time agent instructions for live mode.
*
* The live scripts, not the reference doc, own situational plumbing: every
* event printed by live-poll carries an `_instructions` string describing
* exactly what to do NEXT, with real ids, paths, and line numbers already
* substituted and only the active path's rules included (a svelte-component
* session never sees JSX guidance, and vice versa). live.md stays lean: the
* session contract, harness policy, and design-quality guidance that is not
* situational (identity lock, variation axes, parameter budgets).
*
* Keep these strings imperative, concrete, and short. They are read by an
* agent mid-session; every sentence must earn its tokens. Instructions are
* versioned with the scripts, so they cannot drift from behavior the way a
* hand-maintained doc can.
*/
const PLAN_POINTER = 'Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets.';
function pollCmd(scriptsPath) {
return `node ${scriptsPath}/live-poll.mjs`;
}
function replyCmd(scriptsPath, id, rest) {
return `${pollCmd(scriptsPath)} --reply ${id} ${rest}`;
}
export function instructionsForEvent(event, { scriptsPath = '{{scripts_path}}' } = {}) {
if (!event || typeof event !== 'object') return undefined;
switch (event.type) {
case 'generate':
return generateInstructions(event, scriptsPath);
case 'steer':
return `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: ${replyCmd(scriptsPath, event.id, 'steer_done ["optional short toast"]')} (on failure: --reply ${event.id} error "Short reason"). No pickup ack; poll again immediately after.`;
case 'prefetch':
return `Speculative pre-read, no reply owed: resolve ${JSON.stringify(event.pageUrl || '/')} to its source file (root "/" is usually the boot's pageFile; multi-page sites map /foo to public/foo/index.html; SPAs map all routes to one entry), read it into context, then poll again. Skip if you cannot resolve it confidently.`;
case 'variant_mount_failed':
return `The browser could NOT render variant ${event.variant}${event.url ? ` (module: ${event.url})` : ''}${event.error ? `: ${String(event.error).slice(0, 200)}` : ''}. The user sees a persistent error card, not variants. Fix the variant source files, then reply ${replyCmd(scriptsPath, event.id, 'done --file <manifest or source path>')}; the browser retries on its own. Poll again after the reply.`;
case 'accept':
return acceptInstructions(event, scriptsPath);
case 'discard':
return event?._completionAck?.ok === true
? 'Original restored and durable completion acknowledged; nothing to do. Poll again.'
: `Completion was not acknowledged: run node ${scriptsPath}/live-complete.mjs --id ${event.id} --discarded, then poll again.`;
case 'manual_edit_apply':
return `The user already clicked Apply; never ask, discard, or redirect. Delegate the source edits to the impeccable_manual_edit_applier subagent when available (pass cwd, scripts path, event id, page URL, chunk/deadline, batch, evidencePath); it must not poll or reply. ${event.repair ? 'A `repair` payload is present: the previous Apply changed source but validation failed; fix the CURRENT source, never roll back yourself. ' : ''}Reply exactly once: ${replyCmd(scriptsPath, event.id, `done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`)} (status "partial"/"error" with failed[] when not every entry applied). Then poll again.`;
case 'timeout':
return 'No event arrived; poll again immediately.';
case 'exit':
return `Session over: kill any background poll, then node ${scriptsPath}/live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`;
default:
return undefined;
}
}
function generateInstructions(event, scriptsPath) {
const id = event.id;
const scaffold = event.scaffold;
const steps = [];
if (event.screenshotPath) {
steps.push(`Read the annotated screenshot first: ${event.screenshotPath}. Comment {x,y} positions bind text to the child under that point; strokes read by shape (loop = emphasis on this thing, arrow = direction, cross = delete).`);
} else {
steps.push('No screenshot was sent (the user did not annotate); do not ask for one and do not screenshot the page. Work from element.outerHTML, the computed styles, and the prompt.');
}
if (event.mode === 'insert') {
steps.push(insertScaffoldInstructions(event, scriptsPath));
} else if (scaffold?.previewMode === 'svelte-component') {
steps.push(svelteComponentInstructions(event, scaffold, scriptsPath));
} else if (scaffold && scaffold.sourceWritten === false) {
steps.push(deferredWrapperInstructions(event, scaffold, scriptsPath));
} else if (scaffold) {
steps.push(`The wrapper is already written into ${scaffold.file}. Splice preview CSS plus all ${event.count} variants at line ${scaffold.insertLine} in ONE edit, following the returned cssAuthoring contract (styleTag, selector strategy, forbidden patterns). Each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none.`);
} else {
steps.push(`Preflight could not scaffold${event.scaffoldError ? ` (${event.scaffoldError})` : ''}. Run node ${scriptsPath}/live-wrap.mjs --id ${id} --count ${event.count} --element-id "${event.element?.id || ''}" --classes "${(event.element?.classes || []).join(',')}" --tag "${event.element?.tagName || ''}" --text "<first ~80 chars of the picked element's textContent>". Keep the flags separate; --text disambiguates repeated siblings. On a fallback error, follow live.md's Handle fallback.`);
}
steps.push(event.action && event.action !== 'impeccable'
? `Action is "${event.action}": read reference/${event.action}.md before planning; its MUST params are non-negotiable. ${PLAN_POINTER}`
: `Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. ${PLAN_POINTER}`);
steps.push(`When all ${event.count} variants are delivered: ${replyCmd(scriptsPath, id, 'done --file <project-root-relative path you wrote>')}. Then poll again. If generation fails after the browser flipped to GENERATING, reply --reply ${id} error "Short reason" so the bar resets (never live-accept --discard for this).`);
return steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
}
function svelteComponentInstructions(event, scaffold, scriptsPath) {
const dir = scaffold.componentDir;
const count = event.count;
return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub <style> is seeded with the source rules that style the selection; restyle or delete freely, and know that any seeded rule you do not re-declare is REMOVED from source on accept (the preview never applied it). ALL your CSS goes inside that ONE existing <style> block: Svelte forbids a second top-level style element, and a publish with a non-compiling variant is bounced back to you with file and line. Semantic class selectors only: no @scope, no data-impeccable-* attributes. Params go in ${dir}/params.json keyed by variant number (never an attribute); author knob CSS against var(--p-<id>, default) and :global([data-p-<id>="..."]). Reply with --file ${scaffold.file}. Accept later merges everything into ${scaffold.sourceFile} mechanically; you have no post-accept cleanup.`;
}
function deferredWrapperInstructions(event, scaffold, scriptsPath) {
const insertNote = Number(scaffold.replaceEndLine) < Number(scaffold.replaceStartLine)
? ` (replaceEndLine < replaceStartLine: this is an INSERTION at line ${scaffold.replaceStartLine}; remove nothing)`
: '';
return `The wrapper is NOT in source yet. In ONE edit to ${scaffold.file}: splice preview CSS plus all ${event.count} variants into scaffold.wrapperBlock at the "Variants: insert below this line" marker, then replace lines ${scaffold.replaceStartLine}-${scaffold.replaceEndLine}${insertNote} with the result. Two separate writes reload the framework mid-publish and strand the browser at 0/N. Author CSS per the returned cssAuthoring contract; each variant div holds exactly ONE top-level element (same tag as the original); first visible, others display: none. On JSX/TSX wrap the <style> content in a template literal and use className / style={{...}}.`;
}
function insertScaffoldInstructions(event, scriptsPath) {
const scaffold = event.scaffold;
const base = `Insert mode: net-new content sized around ${event.placeholder?.width || '?'}x${event.placeholder?.height || '?'} at the chosen anchor; load craft-floor.md before writing net-new markup.`;
if (scaffold?.previewMode === 'svelte-component') {
return `${base} Write each inserted variant as a single-root Svelte component under ${scaffold.componentDir} (no data-impeccable-* attributes, CSS in each component's <style>). Never edit the route during generation; reply with --file ${scaffold.file}.`;
}
if (scaffold && scaffold.sourceWritten === false) {
return `${base} Splice your variants into scaffold.wrapperBlock at the marker and insert the result at line ${scaffold.replaceStartLine} of ${scaffold.file} in ONE edit.`;
}
return `${base} If no scaffold payload is present, run node ${scriptsPath}/live-insert.mjs --id ${event.id} --count ${event.count} --position ${event.insert?.position || 'after'} with the anchor flags from event.insert.anchor, then splice variants at the returned insertLine.`;
}
function acceptInstructions(event, scriptsPath) {
const result = event._acceptResult || {};
const ackOk = event._completionAck?.ok === true;
const prefix = ackOk ? '' : `Completion was NOT acknowledged: run node ${scriptsPath}/live-status.mjs, finish any cleanup, then node ${scriptsPath}/live-complete.mjs --id ${event.id}. `;
if (result.handled === true && result.carbonize === true) {
return `${prefix}Carbonize cleanup is REQUIRED now, before the next poll, in ${result.file}: (1) locate the impeccable-carbonize-start/end block and read the impeccable-param-values comment; (2) move the CSS rules into the stylesheet that owns this area; (3) bake params while rewriting selectors (@scope wrappers to semantic classes, keep only the chosen data-p branch, substitute range literals); (4) unwrap the accepted content and drop every data-impeccable-* / data-p-* attribute; (5) delete the inline <style>, the param-values comment, and both markers plus dead @scope rules. Then run node ${scriptsPath}/live-complete.mjs --id ${event.id} and verify phase "completed"; it refuses with source_dirty while leftovers remain. Poll again only after that.`;
}
if (result.handled === true) {
return `${prefix}Accept was merged into source mechanically; nothing to clean up. Poll again.`;
}
if (result.mode === 'fallback') {
return `${prefix}The session lived in a generated file, so accept refused to persist there. Write the accepted variant into the true source you identified during Handle fallback, remove the temporary wrapper from the served file, then poll again.`;
}
if (result.mode === 'error') {
if (result.error === 'source_locked') {
return `${prefix}The source file is briefly locked by a publisher. Re-run the exact same live-accept.mjs command (idempotent); do NOT hand-edit the file, and do not poll past this.`;
}
if (result.error === 'accept_receipt_conflict') {
return `${prefix}This session already resolved as ${result.priorOperation || 'a prior operation'}; do not edit anything. Run node ${scriptsPath}/live-status.mjs and tell the user what the session resolved to.`;
}
return `${prefix}Accept failed: ${result.error || 'unknown error'}. Source was not touched; do not hand-edit. Run node ${scriptsPath}/live-status.mjs before continuing.`;
}
return `${prefix}No mechanical accept result; read ${result.file || 'the session source file'}, find the impeccable markers, and finish the merge by hand. Poll again after.`;
}
/** Boot instructions attached to live.mjs's success payload. */
export function bootInstructions({ scriptsPath = '{{scripts_path}}' } = {}) {
return `Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run ${pollCmd(scriptsPath)} immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.`;
}

View File

@@ -0,0 +1,939 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
import { readBuffer as readManualEditsBuffer } from './manual-edits-buffer.mjs';
const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
export function createManualApplyController({
pendingEvents,
pendingApplyDeferreds,
timedOutApplyIds,
enqueueEvent,
acknowledgePendingEvent,
flushPendingPolls,
recordManualEditActivity,
cwd = () => process.cwd(),
} = {}) {
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
function tombstoneTimedOutApplyId(eventId, details = {}) {
if (!eventId) return;
timedOutApplyIds.set(eventId, details);
if (timedOutApplyIds.size <= 200) return;
const oldest = timedOutApplyIds.keys().next().value;
timedOutApplyIds.delete(oldest);
}
function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
const cwdValue = projectCwd();
const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
const evidencePath = writeManualApplyEvidence(eventId, batch, cwdValue);
const event = {
type: 'manual_edit_apply',
id: eventId,
pageUrl,
batch: compactManualApplyBatch(batch, cwdValue),
evidencePath,
agentAction: buildManualApplyAgentAction(eventId),
schemaVersion: 1,
deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
};
if (chunk) event.chunk = chunk;
if (repair) event.repair = repair;
const rollbackSnapshot = snapshotApplyEventFiles(batch, cwdValue);
recordManualEditActivity('manual_edit_apply_dispatched', {
id: eventId,
pageUrl,
chunk,
repair,
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
opCount: countManualApplyOps(batch),
fileCount: collectManualApplyFiles(batch, [], cwdValue).length,
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingApplyDeferreds.delete(eventId);
tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot, cwd: cwdValue });
acknowledgePendingEvent(eventId);
removeManualApplyEvidence(evidencePath, cwdValue);
recordManualEditActivity('manual_edit_apply_timeout', {
id: eventId,
pageUrl,
chunk,
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
opCount: countManualApplyOps(batch),
});
reject(new Error('chat_agent_timeout'));
}, APPLY_EVENT_HARD_TIMEOUT_MS);
pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot, cwd: cwdValue });
enqueueEvent(event);
});
}
async function pushBatchInChunksAndWait(batch, pageUrl, context = {}) {
const repair = context?.repair || batch?.repair || null;
if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
const expectedOpsByEntry = new Map();
for (const entry of batch?.entries || []) {
expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
}
const appliedOpsByEntry = new Map();
const failedByEntry = new Map();
const files = new Set();
const notes = [];
let aborted = false;
for (const chunk of chunks) {
if (aborted) {
markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
continue;
}
let result;
try {
result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
} catch (err) {
markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
aborted = true;
continue;
}
for (const file of result.files) files.add(file);
notes.push(...result.notes);
const chunkFailedIds = new Set();
for (const item of result.failed) {
const entryId = item.entryId || item.id;
if (!entryId) continue;
chunkFailedIds.add(entryId);
if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, {
entryId,
reason: item.reason || item.message || 'failed',
candidates: Array.isArray(item.candidates) ? item.candidates : [],
});
}
}
if (result.status === 'error') {
markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
aborted = true;
continue;
}
const reportedAppliedIds = new Set(result.appliedEntryIds);
for (const entryId of reportedAppliedIds) {
if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
}
for (const entryId of chunk.entryIds) {
if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
}
}
}
const appliedEntryIds = [];
for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
if (failedByEntry.has(entryId)) continue;
if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
appliedEntryIds.push(entryId);
} else if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
}
}
const failed = [...failedByEntry.values()];
return {
status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
appliedEntryIds,
failed,
files: [...files],
notes,
};
}
function getDeferred(eventId) {
return pendingApplyDeferreds.get(eventId) || null;
}
function hasTimedOutId(eventId) {
return timedOutApplyIds.has(eventId);
}
function resolveDeferred(eventId, body) {
const deferred = pendingApplyDeferreds.get(eventId);
if (!deferred) return false;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
deferred.resolve(body);
return true;
}
function rejectDeferred(eventId, reason) {
const deferred = pendingApplyDeferreds.get(eventId);
if (!deferred) return false;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
deferred.reject(new Error(reason || 'chat_agent_error'));
return true;
}
function referencedManualApplyEvidencePaths(cwdValue = projectCwd()) {
const referenced = new Set();
const add = (event) => {
const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwdValue);
if (fullPath) referenced.add(fullPath);
};
for (const entry of pendingEvents) add(entry.event);
for (const deferred of pendingApplyDeferreds.values()) add(deferred.event);
return referenced;
}
function pruneStaleEvidence(cwdValue = projectCwd()) {
const dir = manualApplyEvidenceDir(cwdValue);
if (!fs.existsSync(dir)) return [];
const referenced = referencedManualApplyEvidencePaths(cwdValue);
const removed = [];
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.json')) continue;
const fullPath = path.join(dir, name);
if (referenced.has(fullPath)) continue;
try {
fs.unlinkSync(fullPath);
removed.push(fullPath);
} catch {
// Stale evidence cleanup is best-effort; Apply verification never relies
// on deleting these files.
}
}
return removed;
}
function rollbackTimedOutReply(msg) {
const details = timedOutApplyIds.get(msg.id);
if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
timedOutApplyIds.delete(msg.id);
return rollbackApplySnapshot(
details.batch,
details.rollbackSnapshot,
msg.data?.files || [],
'stale_manual_edit_apply_reply',
details.cwd || projectCwd(),
);
}
function cancelPendingEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
for (let i = pendingEvents.length - 1; i >= 0; i -= 1) {
const event = pendingEvents[i]?.event;
if (!shouldCancel(event)) continue;
pendingEvents.splice(i, 1);
removeManualApplyEvidence(event.evidencePath, projectCwd());
canceledById.set(event.id, {
id: event.id,
pageUrl: event.pageUrl,
entryCount: event.batch?.entries?.length || 0,
});
}
for (const [eventId, deferred] of [...pendingApplyDeferreds.entries()]) {
if (!shouldCancel(deferred.event)) continue;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
const cwdValue = deferred.cwd || projectCwd();
const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason, cwdValue);
tombstoneTimedOutApplyId(eventId, {
batch: deferred.batch,
rollbackSnapshot: deferred.rollbackSnapshot,
reason,
cwd: cwdValue,
});
removeManualApplyEvidence(deferred.event?.evidencePath, cwdValue);
canceledById.set(eventId, {
id: eventId,
pageUrl: deferred.pageUrl,
entryCount: deferred.batch?.entries?.length || 0,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
});
deferred.reject(new Error(reason));
}
if (canceledById.size > 0) flushPendingPolls();
return [...canceledById.values()];
}
return {
buildAgentAction: buildManualApplyAgentAction,
cancelPendingEvents,
clearTransaction: (transactionId = null) => clearManualApplyTransaction(projectCwd(), transactionId),
countOps: countManualApplyOps,
getDeferred,
hasTimedOutId,
pruneStaleEvidence,
pushBatchInChunksAndWait,
readTransaction: () => readManualApplyTransaction(projectCwd()),
rejectDeferred,
resolveDeferred,
rollbackTimedOutReply,
rollbackTransaction: (opts = {}) => rollbackManualApplyTransaction({
cwd: projectCwd(),
recordManualEditActivity,
...opts,
}),
summarizeEvent: (event = {}, batch = event.batch) => summarizeManualApplyEvent(event, batch, projectCwd()),
validateResultMessage: validateManualApplyResultMessage,
writeTransaction: (opts = {}) => writeManualApplyTransaction({ cwd: projectCwd(), ...opts }),
};
}
export function manualEditApplyChunkSize(env = process.env) {
const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
const size = Math.trunc(raw);
return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
}
export function countManualApplyOps(entriesOrBatch) {
const entries = Array.isArray(entriesOrBatch)
? entriesOrBatch
: Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
let count = 0;
for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
return count;
}
export function writeManualApplyEvidence(eventId, batch, cwd = process.cwd()) {
const dir = manualApplyEvidenceDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const evidencePath = path.join(dir, `${eventId}.json`);
fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
return evidencePath;
}
export function manualApplyEvidenceDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'manual-edit-evidence');
}
export function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
if (!evidencePath || typeof evidencePath !== 'string') return null;
const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
const evidenceDir = manualApplyEvidenceDir(cwd);
const relative = path.relative(evidenceDir, fullPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
if (path.extname(relative) !== '.json') return null;
return fullPath;
}
export function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
if (!fullPath) return false;
try {
fs.unlinkSync(fullPath);
return true;
} catch {
return false;
}
}
export function compactManualApplyBatch(batch = {}, cwd = process.cwd()) {
const entries = (batch.entries || []).map(compactManualApplyEntry);
const candidates = compactManualApplyCandidates(batch.candidates || [], cwd);
return {
version: batch.version,
pageUrl: batch.pageUrl || null,
count: batch.count,
entries,
ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
candidates: candidates.length > 0 ? candidates : undefined,
context: batch.context ? {
bufferPath: batch.context.bufferPath,
totalEntries: batch.context.totalEntries,
totalOps: batch.context.totalOps,
chunkIndex: batch.context.chunkIndex,
chunkTotal: batch.context.chunkTotal,
totalApplyOps: batch.context.totalApplyOps,
} : undefined,
};
}
export function compactManualApplyCandidates(candidates, cwd = process.cwd()) {
return (Array.isArray(candidates) ? candidates : [])
.slice(0, 24)
.map((candidate) => ({
entryId: candidate.entryId,
ref: candidate.ref,
sourceHint: compactManualApplySourceMatch(candidate.sourceHint, cwd),
textMatches: compactManualApplySourceMatches(candidate.textMatches, 8, cwd),
objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8, cwd),
contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8, cwd),
locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6, cwd),
}));
}
function compactManualApplySourceMatches(matches, limit, cwd) {
return (Array.isArray(matches) ? matches : [])
.slice(0, limit)
.map((match) => compactManualApplySourceMatch(match, cwd))
.filter(Boolean);
}
function compactManualApplySourceMatch(match, cwd) {
if (!match || typeof match !== 'object') return null;
const file = match.relativeFile || match.file;
if (!file && !match.line) return null;
return {
file: summarizeManualLogFile(file, cwd),
line: match.line || null,
column: match.column || null,
reason: match.reason || match.kind || undefined,
status: match.status || undefined,
};
}
function compactManualApplyEntry(entry = {}) {
return {
id: entry.id,
pageUrl: entry.pageUrl,
stagedAt: entry.stagedAt || null,
element: compactManualApplyContext(entry.element),
ops: (entry.ops || []).map(compactManualApplyOp),
};
}
function compactManualApplyOp(op = {}) {
return {
entryId: op.entryId,
ref: op.ref,
contextRef: op.contextRef,
tag: op.tag,
elementId: op.elementId,
classes: Array.isArray(op.classes) ? op.classes : [],
originalText: op.originalText,
newText: op.newText,
deleted: op.deleted === true || undefined,
sourceHint: op.sourceHint || null,
leaf: compactManualApplyContext(op.leaf),
nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
container: compactManualApplyContext(op.container),
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
};
}
function compactManualApplyContext(value) {
if (!value || typeof value !== 'object') return null;
return {
ref: value.ref,
tagName: value.tagName || value.tag || null,
id: value.id || null,
classes: Array.isArray(value.classes) ? value.classes : [],
textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
};
}
function compactNearbyManualEditTexts(items) {
return (Array.isArray(items) ? items : [])
.slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
.map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
ref: item?.ref,
tag: item?.tag,
classes: Array.isArray(item?.classes) ? item.classes : [],
text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
});
}
function truncateManualApplyText(value, max) {
if (typeof value !== 'string') return value || null;
return value.length > max ? value.slice(0, max) : value;
}
function normalizeApplyChunkResult(result) {
const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
return {
status,
message: typeof result?.message === 'string' ? result.message : null,
appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
};
}
function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
}
function invalidManualApplyResult(reason, eventId, extra = {}) {
return {
ok: false,
body: {
error: 'invalid_manual_apply_result',
reason,
hint: manualApplyResultShapeHint(eventId),
...extra,
},
};
}
export function validateManualApplyResultMessage(msg, deferred) {
let data = msg?.data;
const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return invalidManualApplyResult('missing_result_data', eventId);
}
if ('entries' in data || 'ops' in data) {
return invalidManualApplyResult('summary_result_not_allowed', eventId);
}
if (!['done', 'partial', 'error'].includes(data.status)) {
return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
}
for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
if (!Array.isArray(data[key])) {
return invalidManualApplyResult(`${key}_must_be_array`, eventId);
}
}
for (const [index, value] of data.appliedEntryIds.entries()) {
if (typeof value !== 'string' || !value) {
return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
}
}
for (const [index, value] of data.files.entries()) {
if (typeof value !== 'string' || !value) {
return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
}
}
for (const [index, value] of data.notes.entries()) {
if (typeof value !== 'string') {
return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
}
}
for (const [index, item] of data.failed.entries()) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
}
if (typeof item.entryId !== 'string' || !item.entryId) {
return invalidManualApplyResult('failed_entryId_required', eventId, { index });
}
if (typeof item.reason !== 'string' || !item.reason) {
return invalidManualApplyResult('failed_reason_required', eventId, { index });
}
}
const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
for (const entryId of data.appliedEntryIds) {
if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
}
}
for (const item of data.failed) {
if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
}
}
if (data.status === 'done') {
if (data.failed.length > 0) {
return invalidManualApplyResult('done_result_has_failed_entries', eventId);
}
if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
}
}
if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
return invalidManualApplyResult('partial_result_has_no_entries', eventId);
}
if (data.status === 'error' && data.appliedEntryIds.length > 0) {
return invalidManualApplyResult('error_result_has_applied_entries', eventId);
}
return {
ok: true,
result: {
status: data.status,
message: typeof data.message === 'string' ? data.message : undefined,
appliedEntryIds: data.appliedEntryIds,
failed: data.failed,
files: data.files,
notes: data.notes,
},
};
}
function firstFailureReason(result) {
const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
return first?.reason || first?.message || null;
}
function markChunkEntriesFailed(failedByEntry, chunk, reason) {
for (const entryId of chunk.entryIds) {
if (failedByEntry.has(entryId)) continue;
failedByEntry.set(entryId, { entryId, reason, candidates: [] });
}
}
export function splitManualApplyBatch(batch, maxOps) {
const totalOpCount = countManualApplyOps(batch);
if (totalOpCount <= maxOps) {
return [{
batch,
meta: null,
entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
}];
}
const rawChunks = [];
let current = createManualApplyChunkBuilder();
for (const entry of batch?.entries || []) {
const ops = entry.ops || [];
if (ops.length <= maxOps) {
if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
for (const op of ops) addOpToManualApplyChunk(current, entry, op);
continue;
}
if (current.opCount > 0) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
for (const op of ops) {
if (current.opCount >= maxOps) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
addOpToManualApplyChunk(current, entry, op);
}
}
if (current.opCount > 0) rawChunks.push(current);
return rawChunks.map((chunk, index) => ({
batch: {
...batch,
count: chunk.opCount,
entries: chunk.entries,
ops: chunk.ops,
candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
context: {
...(batch?.context || {}),
totalEntries: chunk.entries.length,
totalOps: chunk.opCount,
chunkIndex: index + 1,
chunkTotal: rawChunks.length,
totalApplyOps: totalOpCount,
},
},
meta: {
index: index + 1,
total: rawChunks.length,
opCount: chunk.opCount,
totalOpCount,
},
entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
opCountsByEntry: chunk.opCountsByEntry,
}));
}
function createManualApplyChunkBuilder() {
return {
entries: [],
entryById: new Map(),
entryIds: new Set(),
ops: [],
refsByEntry: new Map(),
opCountsByEntry: new Map(),
opCount: 0,
};
}
function addOpToManualApplyChunk(chunk, entry, op) {
let chunkEntry = chunk.entryById.get(entry.id);
if (!chunkEntry) {
chunkEntry = { ...entry, ops: [] };
chunk.entryById.set(entry.id, chunkEntry);
chunk.entryIds.add(entry.id);
chunk.entries.push(chunkEntry);
}
chunkEntry.ops.push(op);
chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
chunk.opCount += 1;
}
function filterManualApplyChunkCandidates(batch, refsByEntry) {
return (batch?.candidates || []).filter((candidate) => {
const refs = refsByEntry.get(candidate.entryId);
if (!refs) return false;
if (!candidate.ref) return true;
return refs.has(candidate.ref);
});
}
export function snapshotApplyEventFiles(batch, cwd = process.cwd()) {
const snapshot = new Map();
for (const relativeFile of collectManualApplyFiles(batch, [], cwd)) {
const absolute = path.resolve(cwd, relativeFile);
try {
snapshot.set(relativeFile, {
exists: fs.existsSync(absolute),
content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
});
} catch {
// If a file cannot be read before dispatch, do not attempt late rollback.
}
}
return snapshot;
}
export function manualApplyTransactionPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
}
export function readManualApplyTransaction(cwd = process.cwd()) {
const file = manualApplyTransactionPath(cwd);
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
}
export function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
const file = manualApplyTransactionPath(cwd);
const files = collectManualApplyFiles(batch, [], cwd);
const transaction = {
version: 1,
id: randomUUID().replace(/-/g, '').slice(0, 8),
createdAt: new Date().toISOString(),
pageUrl,
entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
files: files.map((relativeFile) => {
const absolute = path.resolve(cwd, relativeFile);
const exists = fs.existsSync(absolute);
return {
file: relativeFile,
exists,
content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
};
}),
};
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
fs.renameSync(`${file}.tmp`, file);
return transaction;
}
export function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
const file = manualApplyTransactionPath(cwd);
if (!fs.existsSync(file)) return false;
if (transactionId) {
const existing = readManualApplyTransaction(cwd);
if (existing?.id && existing.id !== transactionId) return false;
}
try {
fs.unlinkSync(file);
return true;
} catch {
return false;
}
}
export function rollbackManualApplyTransaction({
cwd = process.cwd(),
pageUrl = null,
reason = 'manual_edit_transaction_rollback',
recordManualEditActivity = null,
} = {}) {
const transaction = readManualApplyTransaction(cwd);
if (!transaction) return null;
if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
let pendingIds = new Set();
try {
const buffer = readManualEditsBuffer(cwd);
pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
} catch {
pendingIds = new Set(transaction.entryIds || []);
}
const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
if (!shouldRollback) {
clearManualApplyTransaction(cwd, transaction.id);
return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
}
const rolledBackFiles = [];
const rollbackFailures = [];
for (const item of transaction.files || []) {
const relativeFile = normalizeProjectFile(item.file, cwd);
if (!relativeFile) continue;
const absolute = path.resolve(cwd, relativeFile);
try {
if (item.exists) {
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, item.content || '', 'utf-8');
} else if (fs.existsSync(absolute)) {
fs.rmSync(absolute);
}
rolledBackFiles.push(relativeFile);
} catch (err) {
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
}
}
clearManualApplyTransaction(cwd, transaction.id);
recordManualEditActivity?.('manual_edit_transaction_rolled_back', {
id: transaction.id,
pageUrl: transaction.pageUrl || null,
reason,
entryIds: transaction.entryIds || [],
rolledBackFiles: rolledBackFiles.map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean),
rollbackFailures: summarizeManualDiagnostics(rollbackFailures, cwd),
});
return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
}
export function collectManualApplyFiles(batch, extraFiles = [], cwd = process.cwd()) {
const files = [];
for (const entry of batch?.entries || []) {
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
}
for (const candidate of batch?.candidates || []) {
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
for (const item of candidate.textMatches || []) files.push(item.file);
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
for (const item of candidate.locatorMatches || []) files.push(item.file);
for (const item of candidate.contextTextMatches || []) files.push(item.file);
}
files.push(...(extraFiles || []));
return [...new Set(files)]
.map((file) => normalizeProjectFile(file, cwd))
.filter(Boolean);
}
function normalizeProjectFile(file, cwd = process.cwd()) {
if (!file || typeof file !== 'string') return null;
const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
const relative = path.relative(cwd, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
return relative;
}
export function rollbackApplySnapshot(
batch,
rollbackSnapshot,
extraFiles = [],
_reason = 'manual_edit_apply_snapshot_rollback',
cwd = process.cwd(),
) {
const scope = collectManualApplyFiles(batch, extraFiles, cwd);
const rolledBackFiles = [];
const rollbackFailures = [];
for (const relativeFile of scope) {
const before = rollbackSnapshot?.get(relativeFile);
if (!before) continue;
const absolute = path.resolve(cwd, relativeFile);
try {
if (before.exists) {
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, before.content, 'utf-8');
} else if (fs.existsSync(absolute)) {
fs.rmSync(absolute);
}
rolledBackFiles.push(relativeFile);
} catch (err) {
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
}
}
return { rolledBackFiles, rollbackFailures };
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`;
}
export function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
return {
kind: 'manual_edit_apply',
required: 'apply_source_edits_then_reply',
replyCommand: manualApplyReplyCommand(eventOrId),
warning: 'Polling only leases this work item; it does not commit source edits.',
};
}
export function summarizeManualApplyEvent(event = {}, batch = event.batch, cwd = process.cwd()) {
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
return {
pageUrl: event.pageUrl || null,
chunk: event.chunk || null,
entryCount: entries.length,
opCount,
files: collectManualApplyFiles(batch, [], cwd),
};
}
export function summarizeManualApplyFailures(failed, cwd = process.cwd()) {
if (!Array.isArray(failed)) return [];
return failed.slice(0, 20).map((item) => ({
id: item.id || item.entryId || null,
reason: item.reason || item.message || 'failed',
message: compactManualLogText(item.message, 300),
files: Array.isArray(item.files) ? item.files.slice(0, 12).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
checks: summarizeManualDiagnostics(item.checks, cwd),
failures: summarizeManualDiagnostics(item.failures, cwd),
candidates: summarizeManualDiagnostics(item.candidates, cwd),
}));
}
export function summarizeManualDiagnostics(items, cwd = process.cwd()) {
if (!Array.isArray(items) || items.length === 0) return undefined;
return items.slice(0, 12).map((item) => ({
reason: item.reason || item.kind || undefined,
detail: compactManualLogText(item.detail, 220),
message: compactManualLogText(item.message, 300),
file: summarizeManualLogFile(item.file || item.relativeFile, cwd),
line: item.line || undefined,
ref: compactManualLogText(item.ref, 180),
marker: compactManualLogText(item.marker, 120),
files: Array.isArray(item.files) ? item.files.slice(0, 8).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
}));
}
export function summarizeManualLogFile(file, cwd = process.cwd()) {
if (!file || typeof file !== 'string') return undefined;
if (!path.isAbsolute(file)) return file;
const relative = path.relative(cwd, file);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
}
export function compactManualLogText(value, max = 200) {
if (typeof value !== 'string') return undefined;
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= max) return normalized;
return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
}

View File

@@ -0,0 +1,357 @@
import { validateEvent } from './event-validation.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
} from './manual-edits-buffer.mjs';
import {
summarizeManualApplyFailures,
summarizeManualDiagnostics,
summarizeManualLogFile,
} from './manual-apply.mjs';
import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs';
import { commitManualEdits } from '../live-commit-manual-edits.mjs';
export function createManualEditRoutes({
getToken,
manualApply,
recordManualEditActivity,
getManualEditStatus,
chatAgentLikelyActive,
cwd = () => process.cwd(),
env = () => process.env,
} = {}) {
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
const currentEnv = () => typeof env === 'function' ? env() : env || process.env;
return function handleManualEditRoute(req, res, url) {
const p = url.pathname;
// Save stages entries; Apply commits the staged page batch through the
// local AI copy-edit runner.
if (p === '/manual-edit-stash' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
sendJson(res, 400, { error: 'Invalid JSON' });
return;
}
if (msg.token !== getToken()) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
const error = validateEvent({ ...msg, type: 'manual_edits' });
if (error) {
sendJson(res, 400, { error });
return;
}
try {
stageManualEditEntry(projectCwd(), {
id: msg.id,
pageUrl: msg.pageUrl,
element: msg.element,
ops: msg.ops,
});
} catch (err) {
sendJson(res, 500, { error: 'stash_write_failed', message: err.message });
return;
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
const pendingCount = perPage[msg.pageUrl] || 0;
recordManualEditActivity('manual_edit_stashed', {
id: msg.id,
pageUrl: msg.pageUrl,
opCount: msg.ops.length,
pendingCount,
totalCount,
hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size,
});
sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage });
});
return true;
}
if (p === '/manual-edit-stash' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl') || '';
const { totalCount, perPage } = countPendingByPage(projectCwd());
const buffer = readManualEditsBuffer(projectCwd());
const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
sendJson(res, 200, {
count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
perPage,
entries: entriesForPage,
});
return true;
}
if (p === '/manual-edit-commit' && req.method === 'POST') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl');
const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
const existingTransaction = manualApply.readTransaction();
if (repairOnly && !existingTransaction) {
sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' });
return true;
}
const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_commit_recovered_abandoned_transaction',
});
const before = getManualEditStatus();
const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
recordManualEditActivity('manual_edit_commit_started', {
pageUrl,
repairOnly,
pendingCount,
totalCount: before.totalCount,
recoveredTransaction: recoveredTransaction ? {
id: recoveredTransaction.id,
reason: recoveredTransaction.reason,
skipped: recoveredTransaction.skipped,
rolledBackFiles: recoveredTransaction.rolledBackFiles,
rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()),
} : null,
...summarizePendingManualEditBatch(projectCwd(), pageUrl),
});
if (asyncMode) {
sendJson(res, 202, {
status: 'started',
pendingCount,
totalCount: before.totalCount,
perPage: before.perPage,
});
}
(async () => {
let result;
let routedProvider = 'subprocess';
let transaction = null;
let commitBatch = null;
try {
if (pendingCount > 0) {
const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl });
commitBatch = transactionBatch;
if (!repairOnly && manualApply.countOps(transactionBatch) > 0) {
transaction = manualApply.writeTransaction({
pageUrl,
batch: transactionBatch,
});
} else if (repairOnly && existingTransaction) {
transaction = existingTransaction;
}
}
const envValue = currentEnv();
const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
const useChatRoute = requestedMode === 'chat'
|| (requestedMode === 'auto' && chatAgentLikelyActive());
if (useChatRoute) {
routedProvider = 'chat';
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
result = await commitManualEdits({
cwd: projectCwd(),
pageUrl,
provider: 'chat',
env: envValue,
timeoutMs,
chatAvailable: chatAgentLikelyActive,
applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context),
repairOnly,
transactionId: transaction?.id || existingTransaction?.id || null,
batch: commitBatch,
});
} else {
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
result = await commitManualEdits({
cwd: projectCwd(),
pageUrl,
provider,
env: envValue,
timeoutMs,
chatAvailable: chatAgentLikelyActive,
repairOnly,
transactionId: transaction?.id || existingTransaction?.id || null,
batch: commitBatch,
});
}
} catch (err) {
if (transaction) {
manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_commit_exception',
});
}
const message = err.stderr?.toString?.() || err.message;
recordManualEditActivity('manual_edit_commit_failed', {
pageUrl,
provider: routedProvider,
error: 'manual_edit_commit_failed',
message,
transactionId: transaction?.id || null,
});
if (!asyncMode) {
sendJson(res, 500, {
error: 'manual_edit_commit_failed',
message,
});
}
return;
} finally {
if (transaction) {
const shouldKeepTransaction = result?.needsManualDecision === true;
if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id);
}
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
if (result?.needsManualDecision) {
recordManualEditActivity('manual_edit_repair_needs_decision', {
pageUrl,
provider: routedProvider,
transactionId: transaction?.id || existingTransaction?.id || null,
repair: result.repair || null,
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
});
} else {
recordManualEditActivity('manual_edit_commit_done', {
pageUrl,
provider: routedProvider,
reason: result.reason || null,
repair: result.repair || null,
appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
warnings: summarizeManualDiagnostics(result.warnings, projectCwd()),
rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()),
unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined,
noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
cleared: result.cleared || 0,
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
});
}
if (!asyncMode) {
sendJson(res, 200, { ...result, totalCount, perPage });
}
})();
return true;
}
if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
let payload = {};
try { payload = body ? JSON.parse(body) : {}; } catch {
sendJson(res, 400, { error: 'Invalid JSON' });
return;
}
const token = payload.token || url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; }
const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
if (action !== 'rollback') {
sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action });
return;
}
const rollback = manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_user_requested_rollback',
});
const { totalCount, perPage } = countPendingByPage(projectCwd());
const response = {
action,
pageUrl,
rollback,
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
perPage,
};
recordManualEditActivity('manual_edit_repair_rollback_done', response);
sendJson(res, 200, response);
});
return true;
}
if (p === '/manual-edit-discard' && req.method === 'POST') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl');
let discarded;
let discardedEntries = [];
let canceledApplyEvents = [];
let transactionRollback = null;
try {
const buffer = readManualEditsBuffer(projectCwd());
transactionRollback = manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_discarded',
});
if (pageUrl) {
discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl);
} else {
discardedEntries = buffer.entries;
discarded = truncateManualEditsBuffer(projectCwd());
}
canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl);
} catch (err) {
sendJson(res, 500, { error: 'discard_failed', message: err.message });
return true;
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
recordManualEditActivity('manual_edit_discarded', {
pageUrl,
discarded,
canceledApplyIds: canceledApplyEvents.map((event) => event.id),
transactionRollback: transactionRollback ? {
id: transactionRollback.id,
rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [],
rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()),
skipped: transactionRollback.skipped,
} : undefined,
totalCount,
});
sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage });
return true;
}
if (p === '/manual-edit' && req.method === 'POST') {
sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' });
return true;
}
return false;
};
}
function sendJson(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function summarizePendingManualEditBatch(cwd, pageUrl = null) {
try {
const buffer = readManualEditsBuffer(cwd);
const entries = (buffer.entries || [])
.filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
return {
pendingEntryCount: entries.length,
pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
};
} catch (err) {
return { pendingSummaryError: err.message || String(err) };
}
}

View File

@@ -0,0 +1,152 @@
/**
* Shared helpers for the pending-manual-edits buffer on disk.
*
* Location: .impeccable/live/pending-manual-edits.json (project-local).
* Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] }
*
* Each entry corresponds to one Save action from the browser. Ops merge by
* (pageUrl, ref): if the user re-edits the same element before committing, the
* existing entry's `newText` is replaced and `originalText` is kept (it holds
* the real source state).
*/
import fs from 'node:fs';
import path from 'node:path';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
export function getBufferPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), BUFFER_FILENAME);
}
export function readBuffer(cwd = process.cwd()) {
return readBufferInternal(cwd, { strict: false });
}
export function readBufferStrict(cwd = process.cwd()) {
return readBufferInternal(cwd, { strict: true });
}
function readBufferInternal(cwd, { strict }) {
const filePath = getBufferPath(cwd);
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
if (strict) throw new Error('manual_edit_buffer_invalid_schema');
return { version: BUFFER_VERSION, entries: [] };
}
return { version: BUFFER_VERSION, entries: parsed.entries };
} catch (err) {
if (strict && err?.code !== 'ENOENT') {
throw new Error('manual_edit_buffer_unreadable: ' + (err.message || String(err)));
}
return { version: BUFFER_VERSION, entries: [] };
}
}
export function writeBuffer(cwd, buffer) {
const filePath = getBufferPath(cwd);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2));
}
/**
* Merge a new entry into the buffer. For each op in the new entry, if there's
* already a buffered op for the same (pageUrl, ref), update that op's newText
* and keep its original originalText (the true source state). Otherwise add
* the op (creating an entry if needed).
*
* Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref).
*/
export function stageEntry(cwd, newEntry) {
const buf = readBufferStrict(cwd);
const pageUrl = newEntry.pageUrl;
for (const newOp of newEntry.ops) {
let mergedIntoExisting = false;
for (const existing of buf.entries) {
if (existing.pageUrl !== pageUrl) continue;
const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref);
if (existingOpIdx >= 0) {
// Keep the original source text but refresh the latest DOM/source evidence.
existing.ops[existingOpIdx] = {
...newOp,
originalText: existing.ops[existingOpIdx].originalText,
newText: newOp.newText,
deleted: newOp.deleted || false,
};
if (newEntry.element) existing.element = newEntry.element;
existing.stagedAt = new Date().toISOString();
mergedIntoExisting = true;
break;
}
}
if (mergedIntoExisting) continue;
// No existing op for this (pageUrl, ref). Find or create an entry to hold it.
let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id);
if (!entry) {
entry = {
id: newEntry.id,
pageUrl,
element: newEntry.element,
ops: [],
stagedAt: new Date().toISOString(),
};
buf.entries.push(entry);
}
entry.ops.push(newOp);
entry.stagedAt = new Date().toISOString();
}
writeBuffer(cwd, buf);
return buf;
}
/**
* Remove entries matching a predicate. Returns count of removed *ops* (not
* entries) so callers report a unit consistent with truncateBuffer and the
* pill's per-page op count. Empty entries (no ops left) are also pruned.
*/
export function removeEntries(cwd, predicate) {
const buf = readBuffer(cwd);
let removedOps = 0;
const kept = [];
for (const entry of buf.entries) {
if (predicate(entry)) {
removedOps += entry.ops?.length || 0;
} else if (entry.ops && entry.ops.length > 0) {
kept.push(entry);
}
}
buf.entries = kept;
writeBuffer(cwd, buf);
return removedOps;
}
/**
* Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }.
*/
export function countByPage(cwd = process.cwd()) {
const buf = readBuffer(cwd);
const perPage = {};
let totalCount = 0;
for (const entry of buf.entries) {
const n = entry.ops.length;
perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n;
totalCount += n;
}
return { totalCount, perPage };
}
/**
* Truncate the buffer to empty (used by discard-all). Returns the count of
* removed ops.
*/
export function truncateBuffer(cwd) {
const buf = readBuffer(cwd);
let removed = 0;
for (const entry of buf.entries) removed += entry.ops.length;
writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] });
return removed;
}

View File

@@ -0,0 +1,14 @@
export function eventPriority(event = {}) {
if (event.type === 'accept' || event.type === 'discard' || event.type === 'exit') return 0;
if (event.type === 'manual_edit_apply' || event.type === 'steer' || event.type === 'carbonize_cleanup') return 1;
if (event.type === 'generate') return 2;
return 3;
}
export function selectAvailablePendingEvent(entries, { now = Date.now(), types = null } = {}) {
const allowed = types instanceof Set ? types : (Array.isArray(types) ? new Set(types) : null);
return entries
.filter((entry) => !(entry.leaseUntil && entry.leaseUntil > now))
.filter((entry) => !allowed || allowed.has(entry.event?.type))
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.seq - b.seq)[0] || null;
}

View File

@@ -0,0 +1,508 @@
/**
* Live root resolution: the single place that decides which directories a live
* session operates on. Every live entry script resolves this once at startup
* (see enterLiveRoot) instead of trusting its ambient cwd, which is how a
* `cd` used to silently fork the whole system into a second, empty project.
*
* Four distinct roots travel together as one manifest:
*
* appRoot what the dev server serves; where live session state,
* injected adapters, and preview modules live.
* repoRoot the git boundary (falls back to appRoot outside git).
* contextRoot the nearest directory from appRoot up to repoRoot carrying
* PRODUCT.md / DESIGN.md (canonical spot or a fallback dir).
* sessionRoot <appRoot>/.impeccable/live — durable live state.
*
* appRoot detection keys on dev-server config presence (vite/svelte/next/
* astro/nuxt/... config files), not on monorepo brand markers. A nested
* website/ with vite.config.js wins over a repo root that merely has a
* package.json. Workspace declarations are one input, not the gatekeeper.
*
* The resolved manifest is persisted at <appRoot>/.impeccable/live/roots.json
* plus a pointer at <repoRoot>/.impeccable/live/app-root.json when the two
* differ, so a helper invoked from anywhere inside the repo finds the same
* roots the boot decided on. When several apps in one repo run live, the
* pointer follows the most recent boot; per-app roots.json files stay put.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { resolveProjectRoot } from '../context.mjs';
const ROOTS_MANIFEST_VERSION = 1;
const ROOTS_FILE = 'roots.json';
const POINTER_FILE = 'app-root.json';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const CONTEXT_FALLBACK_DIRS = ['.agents/context', 'docs'];
// Presence of any of these marks a directory as a dev-served app root.
const DEV_CONFIG_MARKERS = [
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs',
'svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts',
'next.config.js', 'next.config.mjs', 'next.config.ts',
'astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.cjs',
'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs',
'remix.config.js', 'react-router.config.ts',
'angular.json',
'webpack.config.js', 'webpack.config.ts',
];
const CANDIDATE_SCAN_IGNORED = new Set([
'node_modules', '.git', 'dist', 'build', 'coverage', 'vendor', 'vendors',
'.next', '.nuxt', '.svelte-kit', '.astro', '.turbo', '.cache', '.vercel',
]);
const CANDIDATE_SCAN_DEPTH = 2;
function exists(p) {
try { fs.statSync(p); return true; } catch { return false; }
}
function isDir(p) {
try { return fs.statSync(p).isDirectory(); } catch { return false; }
}
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (exists(abs)) return abs;
}
return null;
}
function hasDevConfig(dir) {
if (DEV_CONFIG_MARKERS.some((name) => exists(path.join(dir, name)))) return true;
// A plain Vite app can run with zero config: index.html + package.json.
return exists(path.join(dir, 'index.html')) && exists(path.join(dir, 'package.json'));
}
function isAppRoot(dir) {
// A directory already configured for live IS an app root, dev config or not
// (plain static multi-page projects have no bundler config).
return hasDevConfig(dir) || exists(path.join(dir, '.impeccable', 'live', 'config.json'));
}
function findContextFile(dir, names) {
const direct = firstExisting(dir, names);
if (direct) return direct;
for (const rel of CONTEXT_FALLBACK_DIRS) {
const nested = firstExisting(path.join(dir, rel), names);
if (nested) return nested;
}
return null;
}
export function findGitRoot(startDir) {
let dir = path.resolve(startDir);
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return null;
if (exists(path.join(dir, '.git'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function walkUp(startDir, upperBound, visit) {
let dir = path.resolve(startDir);
const stop = path.resolve(upperBound);
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return null;
const hit = visit(dir);
if (hit) return hit;
if (dir === stop) return null;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function insideOrEqual(candidate, root) {
const rel = path.relative(path.resolve(root), path.resolve(candidate));
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/**
* Scan downward (bounded depth) for directories carrying a dev-server config.
* Used when live boots from a directory that is not itself an app root and no
* --target narrows the choice: one candidate is auto-picked, several become a
* selection prompt.
*/
export function discoverAppCandidates(rootDir, depth = CANDIDATE_SCAN_DEPTH) {
const found = [];
const scan = (dir, remaining) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || CANDIDATE_SCAN_IGNORED.has(entry.name)) continue;
const abs = path.join(dir, entry.name);
// Same criterion as the upward walk (isAppRoot): a live-configured
// plain-static site with no bundler markers is still an app, and
// missing it here would silently fall back to the wrong root.
if (isAppRoot(abs)) {
found.push(abs);
continue; // nested apps below an app root are that app's business
}
if (remaining > 1) scan(abs, remaining - 1);
}
};
scan(path.resolve(rootDir), depth);
return found.sort();
}
/**
* Fresh root resolution. Never reads a persisted manifest.
*
* Returns { manifest } on success or { selection } when several candidate
* apps exist and nothing disambiguates.
*/
export function resolveRoots({ cwd = process.cwd(), targetPath = null } = {}) {
const absCwd = path.resolve(cwd);
const absTarget = targetPath
? (path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath))
: null;
const targetDir = absTarget
? (isDir(absTarget) ? absTarget : path.dirname(absTarget))
: absCwd;
// The walk bound must be an ancestor of the target: a git root found from
// the CWD is only usable when the target actually lives inside it,
// otherwise the walk would climb out of both trees.
const targetGitRoot = findGitRoot(targetDir);
const cwdGitRoot = targetGitRoot ? null : findGitRoot(absCwd);
const repoRoot = targetGitRoot
|| (cwdGitRoot && insideOrEqual(targetDir, cwdGitRoot) ? cwdGitRoot : null);
// Without a git boundary, never ascend above the starting directory: the
// filesystem above an unversioned project is not ours to interpret.
const upperBound = repoRoot || targetDir;
// The workspace-aware legacy resolution (context.mjs) still decides two
// things: the fallback when no app marker exists, and how far the marker
// walk may ascend when an explicit target selected a workspace child. A
// root-level live config must never shadow a child the target picked.
const legacyRoot = resolveProjectRoot(absCwd, absTarget ? { targetPath: absTarget } : {});
const markerBound = absTarget && insideOrEqual(targetDir, legacyRoot) && insideOrEqual(legacyRoot, upperBound)
? legacyRoot
: upperBound;
let appRoot = walkUp(targetDir, markerBound, (dir) => (isAppRoot(dir) ? dir : null));
let resolvedFrom = appRoot
? (absTarget ? `target:${path.relative(absCwd, absTarget) || '.'}` : 'cwd')
: null;
if (!appRoot && !absTarget) {
const candidates = discoverAppCandidates(absCwd);
if (candidates.length === 1) {
appRoot = candidates[0];
resolvedFrom = `candidate:${path.relative(absCwd, appRoot)}`;
} else if (candidates.length > 1) {
return {
selection: {
candidates: candidates.map((abs) => ({
name: path.basename(abs),
path: path.relative(absCwd, abs).split(path.sep).join('/'),
})),
},
};
}
}
if (!appRoot) {
// No app marker anywhere: defer to the workspace-aware legacy resolution
// (workspace child for a targeted monorepo path, cwd otherwise). Never
// adopt an arbitrary ancestor just because it has a package.json, and
// never adopt a root that does not even contain the target.
appRoot = insideOrEqual(targetDir, legacyRoot) ? legacyRoot : targetDir;
resolvedFrom = 'fallback';
}
const effectiveRepoRoot = repoRoot && insideOrEqual(appRoot, repoRoot) ? repoRoot : appRoot;
// Each context file resolves independently: a child app may carry its own
// PRODUCT.md while inheriting DESIGN.md from the repo root (or vice versa).
const productPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, PRODUCT_NAMES));
const designPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, DESIGN_NAMES));
const contextRoot = productPath
? path.dirname(productPath)
: designPath
? path.dirname(designPath)
: null;
return {
manifest: {
version: ROOTS_MANIFEST_VERSION,
appRoot,
repoRoot: effectiveRepoRoot,
contextRoot,
sessionRoot: path.join(appRoot, '.impeccable', 'live'),
productPath,
designPath,
resolvedFrom,
},
};
}
function rootsFilePath(appRoot) {
return path.join(appRoot, '.impeccable', 'live', ROOTS_FILE);
}
function pointerFilePath(repoRoot) {
return path.join(repoRoot, '.impeccable', 'live', POINTER_FILE);
}
export function writeRootsManifest(manifest) {
const file = rootsFilePath(manifest.appRoot);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
if (path.resolve(manifest.repoRoot) !== path.resolve(manifest.appRoot)) {
const pointer = pointerFilePath(manifest.repoRoot);
fs.mkdirSync(path.dirname(pointer), { recursive: true });
// The pointer records EVERY app that has booted live in this repo, most
// recent first. A single last-boot-wins value made a helper run from the
// repo root silently target whichever app booted last, even while an
// earlier app's session was the one still live.
const entries = readPointerEntries(manifest.repoRoot)
.filter((entry) => path.resolve(entry.appRoot) !== path.resolve(manifest.appRoot));
entries.unshift({ appRoot: manifest.appRoot, bootedAt: new Date().toISOString() });
fs.writeFileSync(pointer, JSON.stringify({ version: 2, appRoots: entries }));
}
return file;
}
function readPointerEntries(repoRoot) {
try {
const raw = JSON.parse(fs.readFileSync(pointerFilePath(repoRoot), 'utf-8'));
if (Array.isArray(raw?.appRoots)) {
return raw.appRoots.filter((entry) => entry && typeof entry.appRoot === 'string');
}
// v1 shape: a single { appRoot } value.
if (raw && typeof raw.appRoot === 'string') return [{ appRoot: raw.appRoot }];
return [];
} catch {
return [];
}
}
/**
* True when the app's live helper server is recorded and its pid is alive.
* A liveness signal alone misclassifies a REUSED pid (helper died without
* removing server.json, the OS handed the pid to something else), so the
* process's command line must also look like a node process; that removes
* reuse by arbitrary processes. A pid reused by another node process remains
* a residual false positive, which the multi-app warning and --target
* escape hatch cover.
*/
function hasLiveServer(appRoot) {
let pid;
let port;
let token;
try {
const info = JSON.parse(fs.readFileSync(path.join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8'));
if (!info || typeof info.pid !== 'number') return false;
pid = info.pid;
port = Number(info.port);
token = typeof info.token === 'string' ? info.token : null;
process.kill(pid, 0);
} catch (err) {
// EPERM: the process exists but is not signalable by this user.
if (err?.code !== 'EPERM') return false;
}
// Liveness alone misclassifies a REUSED pid, and a bare TCP connect
// misclassifies a coincidental listener on a reused port. The decisive
// signal is IDENTITY: the helper answers its authenticated /status
// endpoint with the token server.json records; nothing else on that port
// can. The probe is a spawned node one-liner so it works identically on
// every platform.
if (Number.isInteger(port) && port > 0 && token) {
try {
execFileSync(process.execPath, ['-e', [
"const req = require('node:http').get({ host: '127.0.0.1', port: Number(process.argv[1]), path: '/status?token=' + encodeURIComponent(process.argv[2]), timeout: 1200 }, (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); });",
"req.on('timeout', () => { req.destroy(); process.exit(1); });",
"req.on('error', () => process.exit(1));",
].join(''), String(port), token], { timeout: 4000, stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// Every server.json this codebase has ever written records port + token
// (see writeLiveServerInfo). A record without them is malformed or foreign
// and cannot be authenticated, so it does not count as a live helper;
// resolution falls to the durable-session tier, which is the correct
// recovery path for a stopped or crashed helper anyway.
return false;
}
const TERMINAL_SESSION_PHASES = new Set(['completed', 'discarded']);
/**
* True when the app's durable session store holds a session that is not
* terminal. With every helper server stopped, this is what distinguishes
* "the app whose interrupted session the user is trying to recover" from an
* app that merely booted more recently.
*/
function hasActiveDurableSession(appRoot) {
const dir = path.join(appRoot, '.impeccable', 'live', 'sessions');
let entries;
try {
entries = fs.readdirSync(dir);
} catch {
return false;
}
for (const name of entries) {
if (!name.endsWith('.snapshot.json')) continue;
try {
const snapshot = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
if (snapshot?.phase && !TERMINAL_SESSION_PHASES.has(snapshot.phase)) return true;
} catch { /* skip unreadable snapshots */ }
}
return false;
}
function readManifestAt(appRoot) {
try {
const raw = JSON.parse(fs.readFileSync(rootsFilePath(appRoot), 'utf-8'));
if (!raw || typeof raw.appRoot !== 'string') return null;
// A manifest is only trusted where it claims to live; anything else is a
// copied or stale file.
if (path.resolve(raw.appRoot) !== path.resolve(appRoot)) return null;
return raw;
} catch {
return null;
}
}
/**
* Resolve the roots for the live session governing `cwd`, preferring a
* persisted manifest (written by the boot) over fresh detection:
*
* 1. Walk up from cwd looking for .impeccable/live/roots.json.
* 2. At the git root, follow .impeccable/live/app-root.json to the app.
* 3. Fresh resolveRoots().
*
* Fresh results are NOT persisted here; only the boot (live.mjs / server
* startup) writes manifests, so ad-hoc helper invocations cannot mint
* conflicting truth.
*/
export function resolveLiveRoots(cwd = process.cwd(), { targetPath = null } = {}) {
const absCwd = path.resolve(cwd);
if (!targetPath) {
const persisted = walkUp(absCwd, findGitRoot(absCwd) || absCwd, (dir) => readManifestAt(dir));
if (persisted) return { manifest: persisted, source: 'persisted' };
const gitRoot = findGitRoot(absCwd);
if (gitRoot) {
// Several apps in one repo may have booted live. Preference order:
// a running helper server, then an app whose durable store still holds
// a non-terminal session (the stopped session the user is recovering),
// then the most recent boot. A stale pointer entry must never redirect
// status/poll/accept onto the wrong app's session store.
const candidates = readPointerEntries(gitRoot)
.map((entry) => readManifestAt(entry.appRoot))
.filter(Boolean);
if (candidates.length > 0) {
const liveApps = candidates.filter((manifest) => hasLiveServer(manifest.appRoot));
const recoveringApps = liveApps.length > 0
? liveApps
: candidates.filter((manifest) => hasActiveDurableSession(manifest.appRoot));
const tier = recoveringApps.length > 0 ? recoveringApps : candidates;
// Multiple apps qualifying at the same tier is inherent ambiguity:
// intent is unknowable from the repo root. The choice stays
// deterministic (most recent boot first), but it must be LOUD, not
// silent, so the agent can re-anchor when it meant the other app.
if (tier.length > 1) {
const chosen = tier[0].appRoot;
const others = tier.slice(1).map((manifest) => manifest.appRoot).join(', ');
process.stderr.write(
`[impeccable live] Multiple apps in this repo have live state; using ${chosen}. `
+ `Other candidate(s): ${others}. Run from the app directory (or pass --target) to address a specific app.\n`,
);
}
return { manifest: tier[0], source: 'pointer' };
}
}
}
const fresh = resolveRoots({ cwd: absCwd, targetPath });
if (fresh.selection) return { selection: fresh.selection, source: 'fresh' };
return { manifest: fresh.manifest, source: 'fresh' };
}
/**
* Consume a `--target <path>` / `--target=<path>` pair from an argv array,
* returning the value and removing the tokens so downstream flag parsers
* (which do not know the option) never see them.
*/
export function consumeTargetArg(argv = process.argv) {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--target') {
const value = argv[i + 1];
// A --target with no usable value must not degrade into implicit root
// selection: these helpers mutate session state, and "the most recent
// app" is exactly what the caller was trying NOT to get.
if (typeof value !== 'string' || value === '' || value.startsWith('--')) {
throw new Error('--target requires a path value (use --target <path> or --target=<path>)');
}
argv.splice(i, 2);
return value;
}
if (typeof arg === 'string' && arg.startsWith('--target=')) {
const value = arg.slice('--target='.length);
if (value === '') {
throw new Error('--target requires a path value (use --target <path> or --target=<path>)');
}
argv.splice(i, 1);
return value;
}
}
return null;
}
/**
* Entry-point guard for live CLI scripts: resolve the governing roots and
* make appRoot the process cwd so every downstream path derivation agrees
* with the boot. An explicit `--target <path>` on the helper's command line
* overrides pointer resolution, which is what disambiguates a repo with
* several live apps (the multi-app warning names this escape hatch, so it
* has to actually work on every helper). Returns the manifest. On selection
* ambiguity it stays in the current directory (the boot flow handles
* prompting); a malformed --target exits with an error instead of silently
* falling back to implicit selection, which could mutate the wrong app.
*/
export function enterLiveRoot(cwd = process.cwd()) {
let targetPath;
try {
targetPath = consumeTargetArg(process.argv);
} catch (err) {
console.error(`[impeccable live] ${err.message}`);
process.exit(1);
}
const resolved = resolveLiveRoots(cwd, targetPath ? { targetPath } : {});
if (!resolved.manifest) return null;
const appRoot = resolved.manifest.appRoot;
if (path.resolve(cwd) !== path.resolve(appRoot)) {
// Failing to land on the resolved appRoot must be fatal: a helper that
// silently keeps its ambient cwd derives server, session, and source
// paths from a different project and mutates the wrong state. A manifest
// pointing at a deleted directory is stale ambient truth, not a reason
// to guess.
if (!isDir(appRoot)) {
console.error(`[impeccable live] resolved app root does not exist: ${appRoot} (stale roots manifest? re-run the live boot, or pass --target <path>)`);
process.exit(1);
}
try {
process.chdir(appRoot);
} catch (err) {
console.error(`[impeccable live] could not enter app root ${appRoot}: ${err.message}`);
process.exit(1);
}
}
return resolved.manifest;
}

View File

@@ -0,0 +1,563 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir, safeSessionId } from '../lib/impeccable-paths.mjs';
import { COMPLETED_SESSION_PHASES, GENERATION_FENCED_SESSION_PHASES } from './vocabulary.mjs';
const COMPLETED_PHASES = new Set(COMPLETED_SESSION_PHASES);
export const GENERATION_FENCED_PHASES = new Set(GENERATION_FENCED_SESSION_PHASES);
// The snapshot file carries two bookkeeping fields the snapshot itself does not
// own: how large the journal was when the snapshot was written, and the next
// sequence number. Both are stripped before a snapshot is handed to a caller.
// The byte count is what makes a cached snapshot verifiable — the journal is
// append-only, so a matching size means no event has landed since.
const META_JOURNAL_BYTES = '__journalBytes';
const META_NEXT_SEQ = '__nextSeq';
// TODO(revision-unification): `checkpointRevision`, `browserCheckpointRevision`,
// and `publicationCheckpointRevision` are three counters for two domains.
// `checkpointRevision` is a compatibility mirror of the browser counter kept for
// older readers. Collapsing them means changing what a resumed browser compares
// its local revision against, so it belongs in a pass that owns resume ordering,
// not in a caching change.
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
fs.mkdirSync(rootDir, { recursive: true });
// Derived state per session, keyed by what the journal looked like when it was
// derived. Publisher/complete helpers append from other processes, so the key
// is the journal's own (path, size, mtime) rather than a trusted local write
// count: an append this process did not make invalidates the entry and the
// next read replays. Without the cache every append and every read replayed
// the whole journal, which made a long session quadratic in its own length.
/** @type {Map<string, { snapshot: object, nextSeq: number, journalPath: string, size: number, mtimeMs: number }>} */
const derived = new Map();
function getReadableJournalPath(id) {
const primary = getJournalPath(rootDir, id);
if (fs.existsSync(primary)) return primary;
const legacy = getJournalPath(legacyRootDir, id);
if (fs.existsSync(legacy)) return legacy;
return primary;
}
/**
* The current derived state for a session, from the in-memory cache when the
* journal has not moved, from the snapshot file when that file is provably
* current, and from a full replay otherwise.
*/
function readState(id, { allowSnapshotFile = true } = {}) {
const journalPath = getReadableJournalPath(id);
const stat = statOrNull(journalPath);
const size = stat ? stat.size : -1;
const mtimeMs = stat ? stat.mtimeMs : -1;
const cached = derived.get(id);
if (cached && cached.journalPath === journalPath && cached.size === size && cached.mtimeMs === mtimeMs) {
return cached;
}
if (allowSnapshotFile && stat) {
const hydrated = readSnapshotFile(getSnapshotPath(rootDir, id), id, size);
if (hydrated) {
const entry = { ...hydrated, journalPath, size, mtimeMs };
derived.set(id, entry);
return entry;
}
}
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
const entry = { snapshot: rebuilt.snapshot, nextSeq: rebuilt.nextSeq, journalPath, size, mtimeMs };
derived.set(id, entry);
return entry;
}
function persist(id, snapshot, nextSeq) {
const snapshotPath = getSnapshotPath(rootDir, id);
const journalPath = getReadableJournalPath(id);
const stat = statOrNull(journalPath);
writeSnapshot(snapshotPath, snapshot, { journalBytes: stat ? stat.size : -1, nextSeq });
derived.set(id, {
snapshot,
nextSeq,
journalPath,
size: stat ? stat.size : -1,
mtimeMs: stat ? stat.mtimeMs : -1,
});
}
return {
rootDir,
legacyRootDir,
appendEvent(event) {
const normalized = normalizeEvent(event, sessionId);
const journalPath = getJournalPath(rootDir, normalized.id);
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
// The readable path just moved from legacy to primary; anything derived
// against the old path describes a file this session no longer reads.
derived.delete(normalized.id);
}
// Reuse the derived state when the journal has not changed under us, and
// apply the new event on top of it. Correctness still comes from the
// journal: any append from another process invalidates the entry above
// and this replays before writing, so sequence numbers and phase fences
// are never taken from a stale copy.
const prior = readState(normalized.id);
const entry = {
seq: prior.nextSeq,
id: normalized.id,
type: normalized.type,
ts: new Date().toISOString(),
event: normalized,
};
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
const next = applyEvent(prior.snapshot, entry);
persist(normalized.id, next, prior.nextSeq + 1);
return next;
},
/**
* True when a journal exists for the id in either root. appendEvent
* CREATES a journal for any id it is handed, so callers that should only
* ever touch existing sessions (browser checkpoints, mount acks) check
* here first — otherwise a stale id from another project's browser
* storage materializes a ghost session in this store.
*/
has(id) {
if (!id || typeof id !== 'string') return false;
return fs.existsSync(getJournalPath(rootDir, id))
|| fs.existsSync(getJournalPath(legacyRootDir, id));
},
/**
* Read-only. `live-status` and `live-resume` call this against a session a
* running server owns; writing the snapshot file here made every read a
* write and let a reader's replay of a half-written journal land on disk.
* Snapshot files are written by appendEvent and by flush().
*/
getSnapshot(id = sessionId, opts = {}) {
if (!id) throw new Error('session id required');
const { snapshot } = readState(id);
if (!opts.includeCompleted && COMPLETED_PHASES.has(snapshot.phase)) return null;
return snapshot;
},
/**
* Write the snapshot file for a session without appending an event. The
* durable truth is the journal, so this only refreshes the read cache other
* processes use; callers that need the state itself should use getSnapshot.
*/
flush(id = sessionId) {
if (!id) throw new Error('session id required');
const state = readState(id, { allowSnapshotFile: false });
persist(id, state.snapshot, state.nextSeq);
return state.snapshot;
},
listActiveSessions() {
const ids = new Set();
for (const dir of [legacyRootDir, rootDir]) {
if (!fs.existsSync(dir)) continue;
for (const name of fs.readdirSync(dir)) {
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
}
}
// Each id goes through readState, so a session whose journal has not moved
// since it was last derived costs a stat and nothing more. The server calls
// this on every /status and on every SSE connect.
return [...ids]
.sort()
.map((id) => this.getSnapshot(id))
.filter(Boolean);
},
};
}
function statOrNull(filePath) {
try {
return fs.statSync(filePath);
} catch {
return null;
}
}
/**
* Hydrate derived state from a snapshot file, but only when it provably
* describes the journal as it stands right now. Anything short of an exact byte
* match on an append-only file means events landed after the snapshot was
* written, and the caller replays instead.
*/
function readSnapshotFile(snapshotPath, id, journalBytes) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
if (parsed[META_JOURNAL_BYTES] !== journalBytes) return null;
if (!Number.isInteger(parsed[META_NEXT_SEQ])) return null;
const nextSeq = parsed[META_NEXT_SEQ];
delete parsed[META_JOURNAL_BYTES];
delete parsed[META_NEXT_SEQ];
// The journal owns identity; a snapshot file copied between session ids is
// not a reason to answer with the wrong id.
if (parsed.id !== id) return null;
return { snapshot: { ...baseSnapshot(id), ...parsed }, nextSeq };
}
function normalizeEvent(event, fallbackId) {
if (!event || typeof event !== 'object') throw new Error('event object required');
const id = event.id || fallbackId;
if (!id || typeof id !== 'string') throw new Error('event id required');
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
return { ...event, id };
}
function getJournalPath(rootDir, id) {
return path.join(rootDir, safeSessionId(id) + '.jsonl');
}
function getSnapshotPath(rootDir, id) {
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
}
function baseSnapshot(id) {
return {
id,
phase: 'new',
pageUrl: null,
sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0,
arrivedVariants: 0,
visibleVariant: null,
paramValues: {},
pendingEventSeq: null,
pendingEvent: null,
deliveryLease: null,
checkpointRevision: 0,
browserCheckpointRevision: 0,
publicationCheckpointRevision: 0,
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
generationPhase: null,
generationCompletedAt: null,
generationTimings: {},
variantPlan: null,
generationCanceled: false,
generationCanceledAt: null,
cancelReason: null,
annotationArtifacts: [],
// Render truth. `arrivedVariants` says what the agent published; these say
// what the browser actually got on screen. They are kept alongside the
// published counters rather than replacing them so older readers keep
// working, but they are the only fields that answer "did the user ever see
// a variant".
mountedVariants: [],
mountFailures: [],
renderState: null,
diagnostics: [],
updatedAt: null,
};
}
// How many mount failures a session keeps. The card in the browser shows the
// newest one; the agent needs enough history to spot a variant that fails
// every republish, not the whole retry storm.
const MOUNT_FAILURE_HISTORY = 5;
/**
* `pending` = the agent published and nothing has acked yet, `mounted` = at
* least one variant reached the DOM, `failed` = the browser reported failures
* and nothing ever mounted. A single success outranks any number of failures:
* the user is looking at something.
*/
function deriveRenderState(snapshot) {
if (snapshot.mountedVariants.length > 0) return 'mounted';
if (snapshot.mountFailures.length > 0) return 'failed';
if (snapshot.generationCompletedAt) return 'pending';
return null;
}
function rebuildSnapshotFromJournal(journalPath, id) {
let snapshot = baseSnapshot(id);
const diagnostics = [];
let nextSeq = 1;
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
snapshot = applyEvent(snapshot, entry);
} catch (err) {
diagnostics.push({
error: 'journal_parse_failed',
line: i + 1,
message: err.message,
});
}
}
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
return { snapshot, diagnostics, nextSeq };
}
function applyEvent(snapshot, entry) {
const event = entry.event || entry;
const next = {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
generationTimings: { ...(snapshot.generationTimings || {}) },
variantPlan: snapshot.variantPlan || null,
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
mountedVariants: [...(snapshot.mountedVariants || [])],
mountFailures: [...(snapshot.mountFailures || [])],
renderState: snapshot.renderState ?? null,
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
};
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
next.variantPlan = null;
// A new cycle publishes new files: everything the browser told us about
// the previous batch is now about modules that no longer exist.
next.mountedVariants = [];
next.mountFailures = [];
next.renderState = null;
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variant_plan':
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
next.variantPlan = event.plan ?? next.variantPlan;
}
break;
case 'detector_waivers':
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
next.detectorWaivers = [
...(next.detectorWaivers || []),
...(Array.isArray(event.waivers) ? event.waivers : []),
];
}
break;
case 'agent_phase':
next.generationPhase = event.phase ?? next.generationPhase;
if (event.phase) {
next.generationTimings[event.phase] = {
at: event.at ?? (Date.parse(entry.ts || '') || null),
durationMs: event.durationMs ?? null,
};
}
break;
case 'variants_ready':
case 'agent_done':
if ((next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase))
&& !(event.type === 'agent_done' && event.carbonize === true && next.phase === 'accept_requested')) {
next.diagnostics.push({
error: 'late_generation_event_ignored',
type: event.type,
phase: next.phase,
});
break;
}
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
// Durable completion marker: later browser checkpoints (a resumed page
// reporting phase "generating") regress `phase`, but generation staying
// finished is monotone — the live server keys missed-`done` redelivery
// on this field.
next.generationCompletedAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null;
next.pendingEvent = null;
if (event.carbonize === true) {
next.diagnostics.push({
error: 'carbonize_cleanup_required',
file: event.file || null,
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
});
}
next.renderState = deriveRenderState(next);
break;
case 'variant_mounted': {
const variant = Number(event.variant);
if (!Number.isInteger(variant) || variant < 1) {
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
break;
}
if (!next.mountedVariants.includes(variant)) {
next.mountedVariants = [...next.mountedVariants, variant].sort((a, b) => a - b);
}
next.renderState = deriveRenderState(next);
break;
}
case 'variant_mount_failed': {
const variant = Number(event.variant);
if (!Number.isInteger(variant) || variant < 1) {
next.diagnostics.push({ error: 'malformed_mount_ack', type: event.type, variant: event.variant ?? null });
break;
}
next.mountFailures = [
...next.mountFailures,
{
variant,
url: typeof event.url === 'string' ? event.url : null,
error: typeof event.error === 'string' ? event.error : null,
at: event.at ?? (Date.parse(entry.ts || '') || Date.now()),
},
].slice(-MOUNT_FAILURE_HISTORY);
next.renderState = deriveRenderState(next);
// The failure needs an agent reply, so it must survive a helper
// restart the same way a generate does. Never clobber a still-pending
// generate: a progressive publish can fail an early mount while the
// generate event itself is still leased.
if (!next.pendingEvent) {
next.pendingEvent = toPendingEvent(event);
}
break;
}
case 'checkpoint':
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
{
const revisionDomain = event.revisionDomain === 'publication'
|| (event.reason === 'variants_progress' && !event.owner)
? 'publication'
: 'browser';
const revisionField = revisionDomain === 'publication'
? 'publicationCheckpointRevision'
: 'browserCheckpointRevision';
const currentRevision = next[revisionField]
?? (revisionDomain === 'browser' ? next.checkpointRevision : 0)
?? 0;
if ((event.revision ?? 0) >= currentRevision) {
next.phase = event.phase ?? next.phase;
next[revisionField] = event.revision ?? currentRevision;
if (revisionDomain === 'browser') {
next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner;
}
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
if (revisionDomain === 'browser') next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (revisionDomain === 'browser' && event.paramValues) next.paramValues = { ...event.paramValues };
} else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision, revisionDomain });
}
}
break;
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'accept';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'manual_edit_apply':
next.phase = 'manual_edit_apply_requested';
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'steer':
next.phase = 'steer_requested';
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'carbonize_cleanup':
next.phase = 'carbonize_cleanup_requested';
next.sourceFile = event.file ?? next.sourceFile;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'steer_done':
next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'discard':
next.phase = 'discard_requested';
next.generationCanceled = true;
next.generationCanceledAt = event.at ?? (Date.parse(entry.ts || '') || Date.now());
next.cancelReason = 'discard';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'discarded':
next.phase = 'discarded';
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'complete':
next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'agent_error':
if (next.generationCanceled && event.sourceEventType === 'generate') {
next.diagnostics.push({ error: 'late_generation_event_ignored', type: event.type, phase: next.phase });
break;
}
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
break;
default:
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
break;
}
return next;
}
function toPendingEvent(event) {
const pending = { ...event };
delete pending.token;
return pending;
}
function upsertArtifact(artifacts, artifact) {
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
artifacts.push(artifact);
}
}
function writeSnapshot(snapshotPath, snapshot, meta) {
const payload = {
...snapshot,
[META_JOURNAL_BYTES]: meta?.journalBytes ?? -1,
[META_NEXT_SEQ]: meta?.nextSeq ?? 1,
};
fs.writeFileSync(snapshotPath, JSON.stringify(payload, null, 2) + '\n');
}

View File

@@ -0,0 +1,105 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { getLiveDir, isLiveServerPidReachable } from '../lib/impeccable-paths.mjs';
// Only used to retire a lock whose contents we cannot read (empty or truncated
// by a crash mid-write). A readable lock's fate is decided by its owner's
// liveness instead, so a slow critical section is never swept.
const UNREADABLE_LOCK_STALE_MS = 60_000;
export function sourceLockPath(file, cwd = process.cwd()) {
const digest = createHash('sha256').update(path.resolve(cwd, file)).digest('hex').slice(0, 24);
return path.join(getLiveDir(cwd), 'locks', digest + '.lock');
}
export function withSourceLockSync(file, owner, fn, {
cwd = process.cwd(),
waitMs = 0,
retryMs = 5,
} = {}) {
const lockPath = sourceLockPath(file, cwd);
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
// Identifies this acquisition specifically, so release can tell our own lock
// from a replacement that some other writer created.
const token = randomUUID();
let acquired = false;
while (!acquired) {
clearStaleLock(lockPath);
let fd;
try {
fd = fs.openSync(lockPath, 'wx');
fs.writeFileSync(fd, JSON.stringify({
owner,
token,
pid: process.pid,
at: Date.now(),
file: path.resolve(cwd, file),
}) + '\n');
acquired = true;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
if (Date.now() >= deadline) {
const locked = new Error('source_locked');
locked.code = 'SOURCE_LOCKED';
locked.lockPath = lockPath;
throw locked;
}
sleepSync(Math.max(1, Math.min(Number(retryMs) || 5, deadline - Date.now())));
} finally {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
}
}
try {
return fn();
} finally {
releaseOwnLock(lockPath, token);
}
}
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readLock(lockPath) {
try { return JSON.parse(fs.readFileSync(lockPath, 'utf-8')); } catch { return null; }
}
/**
* Remove the lock only if it is still the one this call created. If a sweeper
* judged our lock stale and another writer replaced it, unlinking here would
* end *their* critical section and admit a third writer to the same file.
*/
function releaseOwnLock(lockPath, token) {
const held = readLock(lockPath);
if (held && held.token !== token) return;
try { fs.unlinkSync(lockPath); } catch {}
}
/**
* A lock is stale when its owner is gone, not when it is old.
*
* Age alone cuts both ways: it sweeps a live holder whose critical section
* outran the timeout (a suspended laptop, a stopped process), letting two
* writers into the same source file, while still making every accept on a
* crashed holder's file wait out the full timeout. Asking the OS whether the
* recorded pid is alive answers both correctly: a dead owner releases at once,
* and a live owner keeps its lock however long it needs.
*/
function clearStaleLock(lockPath) {
const held = readLock(lockPath);
if (!held) {
// Unreadable: either a crash truncated it, or we caught the brief window
// between create and write in a live acquisition. mtime distinguishes them.
try {
const stat = fs.statSync(lockPath);
if (Date.now() - stat.mtimeMs > UNREADABLE_LOCK_STALE_MS) fs.unlinkSync(lockPath);
} catch { /* gone already */ }
return;
}
if (typeof held.pid === 'number' && isLiveServerPidReachable(held.pid)) return;
try { fs.unlinkSync(lockPath); } catch {}
}

View File

@@ -0,0 +1,105 @@
/**
* The project-source walk shared by live-wrap.mjs and live-accept.mjs.
*
* Both scripts need the same thing: find the one project file containing a
* string (wrap looks for the element's class/id/text, accept looks for the
* session's `impeccable-variants-start` marker). They had two near-identical
* copies of the walk, and the copies drifted — same `EXTENSIONS` array declared
* twice, same `searchDirs` array declared twice, one `realpathSync` guarded by
* try/catch and the other not. That drift is what #374 had to patch in two
* places at once.
*
* Callers differ only in how they reject a candidate, so that is the one thing
* this module takes as options (`skipDirs`, `fileFilter`).
*/
import fs from 'node:fs';
import path from 'node:path';
import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs';
import { matchesTemplateExtension } from '../lib/template-extensions.mjs';
/**
* Privileged roots, searched in order, before the catch-all `.` walk.
*
* `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is
* an ordering preference rather than a reachability fix: `.` already recurses
* into `lib`, so the real #374 bug was the extension list, not this array.
*/
export const SOURCE_SEARCH_DIRS = Object.freeze([
'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.',
]);
/**
* Directories that are never project source.
*
* `.impeccable` is the critical entry, and it is not cosmetic. Progressive
* publication stages each revision as `.impeccable/live/artifacts/
* <id>-r<n>.<source-ext>`, and those artifacts carry the very marker accept
* searches for. The walk reaches `.` for any project whose source is not under
* one of the privileged roots above (this repo's own site lives in
* `site/pages/`), and dot-directories sort before letters, so the artifact was
* found *before* the real file. isGeneratedFile then declined the accept, and
* the agent fell back to carbonizing several hundred lines of stylesheet by
* hand.
*/
export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]);
const MAX_DEPTH = 5;
/**
* Walk the project for the first template file whose contents include `query`.
*
* @param {object} opts
* @param {string} opts.query substring to find in file contents
* @param {string} opts.cwd project root
* @param {string[]} opts.extensions filename suffixes that count as templates
* @param {Iterable<string>} [opts.skipDirs] directory names never to descend into
* @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate
* @returns {string|null} absolute path of the first match
*/
export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) {
const skip = new Set(skipDirs);
const seen = new Set();
for (const dir of SOURCE_SEARCH_DIRS) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0);
if (result) return result;
}
return null;
}
function walk(dir, query, extensions, skip, fileFilter, seen, depth) {
if (depth > MAX_DEPTH) return null;
// A broken symlink anywhere in the tree used to throw straight out of
// live-wrap's copy of this walk, killing the whole wrap.
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
// Files before directories: a match in the current directory beats one
// nested deeper.
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!matchesTemplateExtension(entry.name, extensions)) continue;
const filePath = path.join(dir, entry.name);
if (fileFilter && !fileFilter(filePath)) continue;
try {
if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath;
} catch { /* unreadable, skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (skip.has(entry.name)) continue;
const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1);
if (result) return result;
}
return null;
}

View File

@@ -0,0 +1,969 @@
/**
* AST-based Svelte scaffolding for live component previews.
*
* The scaffolder turns the selected block of a route's markup into a detached
* preview component whose dynamic values arrive as props. The old
* implementation matched `{...}` with a regex, which flattened control-flow
* blocks ({#each}, {#if}) into scalar text props and shipped structurally
* wrong previews. This module uses the app's own svelte compiler
* (parse with modern: true) and replaces only expressions that are FREE,
* i.e. reference identifiers not bound by an enclosing template scope:
*
* {#each stages as stage, i} stages -> collection prop (array)
* <span>{stage.label}</span> bound -> left verbatim
* {/each}
* <p>{footerNote}</p> free -> text prop (string)
*
* Constructs that cannot work in a detached component (component tags whose
* imports live in the route file, bind:/use: directives, await blocks,
* render tags) mark the analysis unsupported; the caller falls back to
* source-preview mode, which keeps the markup inside the route file where
* those references still resolve. A wrong preview is worse than a plain one.
*
* The compiler is resolved from the APP's node_modules, never bundled: the
* preview must be parsed by the same svelte version that will compile it.
*/
import { createRequire } from 'node:module';
import path from 'node:path';
const HANDLER_ATTR_RE = /^on[a-z]/;
/**
* Resolve the app's svelte compiler synchronously (svelte 5 ships a CJS
* compiler build, so createRequire works and the accept/scaffold pipeline
* stays synchronous). Returns { parse, compile, VERSION } or null.
*/
export function loadSvelteCompiler(appRoot) {
try {
const req = createRequire(path.join(appRoot, 'package.json'));
const mod = req('svelte/compiler');
if (typeof mod.parse !== 'function') return null;
const major = parseInt(String(mod.VERSION || '0'), 10);
if (major < 5) return null; // detached mount() previews are svelte 5 only
return { parse: mod.parse, compile: mod.compile, VERSION: mod.VERSION };
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// ESTree helpers
// ---------------------------------------------------------------------------
/**
* Collect the root identifiers an ESTree expression reads. Walks generically;
* skips non-computed member properties and non-computed/non-shorthand object
* keys, which are names, not references.
*/
export function collectRootIdentifiers(node, out = new Set()) {
if (!node || typeof node !== 'object') return out;
if (Array.isArray(node)) {
for (const item of node) collectRootIdentifiers(item, out);
return out;
}
switch (node.type) {
case 'Identifier':
out.add(node.name);
return out;
case 'MemberExpression':
collectRootIdentifiers(node.object, out);
if (node.computed) collectRootIdentifiers(node.property, out);
return out;
case 'Property':
if (node.computed) collectRootIdentifiers(node.key, out);
collectRootIdentifiers(node.value, out);
return out;
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
// Params shadow outer names inside the body.
const bound = new Set();
for (const param of node.params || []) collectPatternNames(param, bound);
const inner = collectRootIdentifiers(node.body, new Set());
for (const name of inner) if (!bound.has(name)) out.add(name);
return out;
}
default: {
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
collectRootIdentifiers(node[key], out);
}
return out;
}
}
}
/** Collect names bound by a destructuring pattern (each contexts, const tags). */
export function collectPatternNames(pattern, out = new Set()) {
if (!pattern || typeof pattern !== 'object') return out;
switch (pattern.type) {
case 'Identifier':
out.add(pattern.name);
return out;
case 'ObjectPattern':
for (const prop of pattern.properties || []) {
if (prop.type === 'RestElement') collectPatternNames(prop.argument, out);
else collectPatternNames(prop.value, out);
}
return out;
case 'ArrayPattern':
for (const el of pattern.elements || []) if (el) collectPatternNames(el, out);
return out;
case 'AssignmentPattern':
collectPatternNames(pattern.left, out);
return out;
case 'RestElement':
collectPatternNames(pattern.argument, out);
return out;
default:
return out;
}
}
// ---------------------------------------------------------------------------
// Template analysis
// ---------------------------------------------------------------------------
class Analysis {
constructor(source) {
this.source = source;
this.replacements = []; // { start, end, prop } source ranges to swap
this.contract = []; // [{ prop, expr, kind, ... }]
this.byExpr = new Map(); // expr text -> contract entry
this.usedNames = new Set();
this.unsupported = null;
}
fail(reason) {
if (!this.unsupported) this.unsupported = reason;
}
propFor(exprText, kind, extra = {}) {
const existing = this.byExpr.get(exprText);
if (existing) return existing;
const base = derivePropName(exprText);
let name = base;
let n = 2;
while (this.usedNames.has(name)) name = `${base}${n++}`;
this.usedNames.add(name);
const entry = { prop: name, expr: exprText, kind, ...extra };
this.byExpr.set(exprText, entry);
this.contract.push(entry);
return entry;
}
}
// A derived prop name lands in `let { <name> } = $props()`; a reserved word
// there is a syntax error the session only hits at import time.
const RESERVED_PROP_NAMES = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false',
'finally', 'for', 'function', 'if', 'implements', 'import', 'in',
'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private',
'protected', 'public', 'return', 'static', 'super', 'switch', 'this',
'throw', 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while',
'with', 'yield',
]);
export function derivePropName(expr) {
const tail = String(expr).match(/(?:\.|\[["']?)([A-Za-z_$][\w$]*)["']?\]?\s*$/);
const candidate = (tail && tail[1])
|| (String(expr).match(/^([A-Za-z_$][\w$]*)$/) || [])[1]
|| 'value';
return RESERVED_PROP_NAMES.has(candidate) ? `${candidate}Value` : candidate;
}
function exprText(source, node) {
return source.slice(node.start, node.end);
}
// Identifiers that resolve in ANY module scope. They are neither hydratable
// props nor evidence of route coupling, so they count as neither free nor
// bound: `{Math.round(x)}` must not mint a prop named `round`, and
// `{fmt(stage.label)}` must not pass as global-only.
const GLOBAL_IDENTIFIERS = new Set([
'Math', 'JSON', 'Date', 'Intl', 'Number', 'String', 'Boolean', 'Array',
'Object', 'Map', 'Set', 'Promise', 'RegExp', 'NaN', 'Infinity', 'undefined',
'isNaN', 'isFinite', 'parseInt', 'parseFloat', 'encodeURIComponent',
'decodeURIComponent', 'console', 'window', 'document', 'navigator',
'location', 'structuredClone', 'crypto',
]);
function classifyRoots(node, scopes) {
const roots = collectRootIdentifiers(node);
let bound = 0;
let free = 0;
for (const name of roots) {
if (GLOBAL_IDENTIFIERS.has(name)) continue;
if (scopes.some((scope) => scope.has(name))) bound++;
else free++;
}
return { bound, free };
}
function isFree(node, scopes) {
const { bound, free } = classifyRoots(node, scopes);
return free > 0 && bound === 0;
}
/**
* An expression mixing loop-bound and outer free identifiers (e.g.
* `{fmt(stage.label)}` where `fmt` lives in the route script) can neither
* become a prop (the bound part varies per item) nor survive detachment
* verbatim (the free name is undeclared in the preview and throws at mount,
* past the compile gate, because globals make it legal to the compiler).
* Source-preview mode is the only correct home for it.
*/
function failOnMixedExpression(node, scopes, analysis, source) {
const { bound, free } = classifyRoots(node, scopes);
if (bound > 0 && free > 0) {
analysis.fail(`expression mixing loop and outer identifiers ({${exprText(source, node).slice(0, 60)}}) requires source-preview mode`);
return true;
}
return false;
}
/**
* Analyze a parsed template fragment. `scopes` is a stack of Sets of bound
* names; the outermost call passes an empty stack.
*/
function analyzeFragment(fragment, analysis, scopes) {
if (!fragment || !Array.isArray(fragment.nodes)) return;
// ConstTag declarations bind for the whole fragment.
const fragmentScope = new Set();
const nextScopes = [...scopes, fragmentScope];
for (const node of fragment.nodes) {
if (node.type === 'ConstTag' && node.declaration) {
for (const decl of node.declaration.declarations || []) {
collectPatternNames(decl.id, fragmentScope);
}
}
}
for (const node of fragment.nodes) analyzeNode(node, analysis, nextScopes);
}
function analyzeNode(node, analysis, scopes) {
if (!node || analysis.unsupported) return;
switch (node.type) {
case 'Text':
case 'Comment':
return;
case 'ExpressionTag': {
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'text');
// node.start/end include the braces; keep them, swap the inside.
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
return;
}
case 'HtmlTag': {
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'raw');
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
return;
}
case 'ConstTag': {
// Its expression may read free names; leave them: the declaration
// travels with the markup and stays valid only if its inputs do.
if (node.declaration) {
for (const decl of node.declaration.declarations || []) {
if (decl.init && failOnMixedExpression(decl.init, scopes, analysis, analysis.source)) return;
if (decl.init && isFree(decl.init, scopes)) {
const text = exprText(analysis.source, decl.init);
const entry = analysis.propFor(text, 'text');
analysis.replacements.push({ start: decl.init.start, end: decl.init.end, prop: entry.prop });
}
}
}
return;
}
case 'EachBlock': {
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const item = describeEachItem(node, analysis.source);
// Keyed each: the key must evaluate to a distinct value per hydrated
// item or Svelte throws each_key_duplicate at mount. A key that is a
// plain member of the item (the common `(item.id)` shape) gets a
// synthetic per-index value injected by the browser (keyField).
// Anything else cannot be hydrated safely; source-preview mode keeps
// it correct.
if (node.key) {
const keyInfo = classifyEachKey(node);
if (keyInfo.unsupported) {
analysis.fail(keyInfo.unsupported);
return;
}
if (keyInfo.keyField) {
if (item.textSlots.some((slot) => slot.key === keyInfo.keyField)) {
// The key doubles as a displayed slot; a synthetic value would
// change visible text, and the displayed text may not be
// unique. Not previewable in a detached component.
analysis.fail('each key that is also a displayed field requires source-preview mode');
return;
}
item.keyField = keyInfo.keyField;
}
}
const entry = analysis.propFor(text, 'collection', { item });
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
analyzeFragment(node.body, analysis, [...scopes, bound]);
if (node.fallback) analyzeFragment(node.fallback, analysis, scopes);
return;
}
case 'IfBlock': {
if (failOnMixedExpression(node.test, scopes, analysis, analysis.source)) return;
if (isFree(node.test, scopes)) {
const text = exprText(analysis.source, node.test);
// The browser hydrates a free condition from what the live page
// currently shows: when the consequent's root element is present
// under the picked element, the condition is on.
const entry = analysis.propFor(text, 'condition', {
probe: describeElementProbe(node.consequent),
});
analysis.replacements.push({ start: node.test.start, end: node.test.end, prop: entry.prop });
}
analyzeFragment(node.consequent, analysis, scopes);
if (node.alternate) analyzeFragment(node.alternate, analysis, scopes);
return;
}
case 'KeyBlock': {
if (failOnMixedExpression(node.expression, scopes, analysis, analysis.source)) return;
if (isFree(node.expression, scopes)) {
const text = exprText(analysis.source, node.expression);
const entry = analysis.propFor(text, 'text');
analysis.replacements.push({ start: node.expression.start, end: node.expression.end, prop: entry.prop });
}
analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'SnippetBlock': {
const bound = new Set();
for (const param of node.parameters || []) collectPatternNames(param, bound);
// The snippet's own name becomes available to render tags in this file.
analyzeFragment(node.body, analysis, [...scopes, bound]);
return;
}
case 'RegularElement':
case 'SlotElement':
case 'TitleElement': {
if (node.name === 'script') {
// An inline script inside the selected block carries route-scoped
// code; running it a second time from a detached preview is wrong.
analysis.fail('inline script element requires source-preview mode');
return;
}
analyzeAttributes(node, analysis, scopes);
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'SvelteElement':
case 'SvelteFragment':
case 'SvelteBoundary': {
analyzeAttributes(node, analysis, scopes);
if (!analysis.unsupported) analyzeFragment(node.fragment, analysis, scopes);
return;
}
case 'Component':
case 'SvelteComponent':
case 'SvelteSelf':
// The component's import lives in the route file; a detached preview
// cannot resolve it. Source-preview mode keeps it working.
analysis.fail(`component tag <${node.name || 'Component'}> requires source-preview mode`);
return;
case 'RenderTag':
analysis.fail('render tag requires source-preview mode');
return;
case 'AwaitBlock':
analysis.fail('await block requires source-preview mode');
return;
case 'SvelteHead':
case 'SvelteWindow':
case 'SvelteDocument':
case 'SvelteBody':
analysis.fail(`${node.type} requires source-preview mode`);
return;
default: {
if (node.fragment) analyzeFragment(node.fragment, analysis, scopes);
return;
}
}
}
function analyzeAttributes(node, analysis, scopes) {
for (const attr of node.attributes || []) {
switch (attr.type) {
case 'Attribute': {
if (attr.value === true) break;
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
for (const part of parts) {
if (!part || part.type !== 'ExpressionTag') continue;
if (failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) return;
if (!isFree(part.expression, scopes)) continue;
const text = exprText(analysis.source, part.expression);
const kind = HANDLER_ATTR_RE.test(attr.name) ? 'handler' : 'text';
const entry = analysis.propFor(text, kind);
analysis.replacements.push({ start: part.expression.start, end: part.expression.end, prop: entry.prop });
}
break;
}
case 'ClassDirective': {
const expr = attr.expression;
if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
if (expr && isFree(expr, scopes)) {
const text = exprText(analysis.source, expr);
// The directive's class name is literal, so the live DOM answers
// the condition directly: the class is either present or not.
const entry = analysis.propFor(text, 'condition', {
probe: { className: attr.name },
});
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
}
break;
}
case 'StyleDirective': {
// Unlike ClassDirective, a style directive stores its value in
// attribute shape: `true` for the shorthand, else an array of parts.
const parts = attr.value === true ? [] : (Array.isArray(attr.value) ? attr.value : [attr.value]);
for (const part of parts) {
if (part?.type === 'ExpressionTag'
&& failOnMixedExpression(part.expression, scopes, analysis, analysis.source)) {
return;
}
}
const dynamic = parts.some((part) => part?.type === 'ExpressionTag' && isFree(part.expression, scopes));
const shorthandFree = attr.value === true && isFree({ type: 'Identifier', name: attr.name }, scopes);
if (dynamic || shorthandFree) {
// style:opacity={x} carries a css VALUE, not a boolean, and the
// computed value on the live element is not reliably recoverable in
// the shape the expression produced. A falsified style is worse
// than an HMR-resetting preview.
analysis.fail(`style:${attr.name} with a dynamic value requires source-preview mode`);
}
break;
}
case 'BindDirective':
analysis.fail(`bind:${attr.name} requires source-preview mode`);
return;
case 'UseDirective':
analysis.fail(`use:${attr.name} requires source-preview mode`);
return;
case 'AnimateDirective':
case 'TransitionDirective':
// Motion directives reference route-scoped or svelte/transition
// imports; a detached preview cannot resolve them.
analysis.fail(`${attr.type} requires source-preview mode`);
return;
case 'OnDirective': {
// Legacy on:click syntax; treat like handler attributes.
const expr = attr.expression;
if (expr && failOnMixedExpression(expr, scopes, analysis, analysis.source)) return;
if (expr && isFree(expr, scopes)) {
const text = exprText(analysis.source, expr);
const entry = analysis.propFor(text, 'handler');
analysis.replacements.push({ start: expr.start, end: expr.end, prop: entry.prop });
}
break;
}
case 'SpreadAttribute':
analysis.fail('spread attribute requires source-preview mode');
return;
default:
break;
}
}
}
/**
* Describe the repeating item of an each block for browser-side hydration:
* the item's root element (tag + static classes, used to count live
* iterations) and the ordered text slots that reference loop bindings.
*/
function describeEachItem(node, source) {
const body = node.body;
const rootEl = (body?.nodes || []).find((n) => n.type === 'RegularElement');
const textSlots = [];
const staticTexts = [];
let nestedUnsupported = false;
const collectStatics = (fragment) => {
for (const child of fragment?.nodes || []) {
if (child.type === 'Text') {
const trimmed = String(child.data || '').trim();
if (trimmed) staticTexts.push(trimmed);
} else if (child.type === 'IfBlock') {
collectStatics(child.consequent);
if (child.alternate) collectStatics(child.alternate);
} else if (child.type === 'EachBlock') {
collectStatics(child.body);
} else if (child.fragment) {
collectStatics(child.fragment);
}
}
};
collectStatics(body);
const attrSlots = [];
// The hydration item is a SHALLOW object whose string fields are the exact
// property names the markup accesses, filled from the rendered page. That
// model supports one item access per slot, optionally wrapped in a global
// transform ({Math.round(r.score)} hydrates `score`). Shapes it cannot
// represent split two ways: CRASHY ones would throw at mount time against a
// shallow item (deep paths like r.meta.label, method calls like r.format())
// and force the source-preview fallback; LOSSY ones render wrong but safe
// (bare {r}, multi-access expressions that would double their text) and
// also fall back in text position, where the damage is visible.
const boundAs = (name, scopeInfos) => {
for (let i = scopeInfos.length - 1; i >= 0; i--) {
const info = scopeInfos[i];
if (info.indexName === name) return 'index';
if (info.itemName === name) return 'item';
if (info.names.has(name)) return 'field';
}
return null;
};
const slotKeysOf = (expression, scopeInfos) => {
const keys = new Set();
let crashy = false;
let lossy = false;
let touches = false;
const visit = (node, ctx) => {
if (!node || typeof node !== 'object' || crashy) return;
if (Array.isArray(node)) {
for (const item of node) visit(item, {});
return;
}
switch (node.type) {
case 'Identifier': {
const kind = boundAs(node.name, scopeInfos);
if (!kind) return;
touches = true;
if (kind === 'index') return; // the runtime each provides it
if (kind === 'item') { lossy = true; return; } // bare item reference
if (ctx.callee) { crashy = true; return; } // field() on a hydrated string
keys.add(node.name); // destructured context field
return;
}
case 'MemberExpression': {
if (
!node.computed
&& node.object?.type === 'Identifier'
&& boundAs(node.object.name, scopeInfos) === 'item'
&& node.property?.type === 'Identifier'
) {
touches = true;
// item.a.b or item.method(): a shallow string field throws here.
if (ctx.memberObject || ctx.callee) { crashy = true; return; }
keys.add(node.property.name);
return;
}
visit(node.object, { memberObject: true });
if (node.computed) visit(node.property, {});
return;
}
case 'CallExpression':
visit(node.callee, { callee: true });
for (const arg of node.arguments || []) visit(arg, {});
return;
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
// Closures cannot hydrate; only lossy when they capture the item.
const roots = collectRootIdentifiers(node);
if ([...roots].some((name) => boundAs(name, scopeInfos))) { touches = true; lossy = true; }
return;
}
case 'Property':
if (node.computed) visit(node.key, {});
visit(node.value, {});
return;
default: {
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
visit(node[key], {});
}
}
}
};
visit(expression, {});
if (crashy) return { crashy: true };
if (lossy || keys.size > 1) return { lossy: true };
if (!touches || keys.size === 0) return { skip: true };
return { key: [...keys][0] };
};
const staticClassesOf = (el) => {
const classes = [];
for (const attr of el?.attributes || []) {
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
}
}
}
return classes;
};
const scopeInfoOf = (eachNode) => {
const names = new Set();
if (eachNode.context) collectPatternNames(eachNode.context, names);
return {
names,
itemName: eachNode.context?.type === 'Identifier' ? eachNode.context.name : null,
indexName: eachNode.index || null,
};
};
const walkForSlots = (fragment, scopeInfos) => {
for (const child of fragment?.nodes || []) {
if (child.type === 'ExpressionTag') {
const slot = slotKeysOf(child.expression, scopeInfos);
if (slot.crashy || slot.lossy) { nestedUnsupported = true; continue; }
if (slot.skip) continue;
textSlots.push({ key: slot.key, expr: exprText(source, child.expression) });
} else if (child.type === 'RegularElement' || child.type === 'SvelteElement') {
// Bound values in ATTRIBUTES (href={link.href}, src={item.img}) are
// part of the item too: the browser reads the rendered attribute off
// the live element, so the preview does not mount with empty links.
// Only a single-expression attribute hydrates exactly; a mixed value
// ("card {r.status}") stays unhydrated because the rendered attribute
// is not separable into its parts, which was the prior behavior.
for (const attr of child.attributes || []) {
if (attr.type !== 'Attribute' || attr.value === true) continue;
if (HANDLER_ATTR_RE.test(attr.name)) continue; // functions cannot hydrate
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
const exprParts = parts.filter((part) => part?.type === 'ExpressionTag');
for (const part of exprParts) {
const slot = slotKeysOf(part.expression, scopeInfos);
if (slot.crashy) { nestedUnsupported = true; continue; }
if (slot.skip || slot.lossy) continue;
if (parts.length !== 1) continue; // mixed static+dynamic value
attrSlots.push({
key: slot.key,
expr: exprText(source, part.expression),
attr: attr.name,
tag: child.name || null,
classes: staticClassesOf(child),
});
}
}
walkForSlots(child.fragment, scopeInfos);
continue;
} else if (child.type === 'EachBlock') {
const roots = collectRootIdentifiers(child.expression);
const boundNested = [...roots].some((name) => boundAs(name, scopeInfos));
if (boundNested) nestedUnsupported = true; // nested per-item arrays: no hydration plan yet
walkForSlots(child.body, [...scopeInfos, scopeInfoOf(child)]);
} else if (child.type === 'IfBlock') {
walkForSlots(child.consequent, scopeInfos);
if (child.alternate) walkForSlots(child.alternate, scopeInfos);
} else if (child.fragment) {
walkForSlots(child.fragment, scopeInfos);
}
}
};
walkForSlots(body, [scopeInfoOf(node)]);
const staticClasses = [];
for (const attr of rootEl?.attributes || []) {
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part.type === 'Text') staticClasses.push(...part.data.split(/\s+/).filter(Boolean));
}
}
}
return {
rootTag: rootEl?.name || null,
rootClasses: staticClasses,
textSlots,
attrSlots,
staticTexts,
nestedUnsupported,
};
}
/**
* Classify a keyed each block's key expression:
* { keyField } member of the loop item (e.g. `(expense.id)` when the
* context binds `expense`): browser injects a unique
* per-index value under that field.
* {} key is the whole loop item or the index: already
* distinct per iteration, nothing to inject.
* { unsupported } free or complex keys: cannot hydrate distinct values.
*/
function classifyEachKey(node) {
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
const key = node.key;
const roots = collectRootIdentifiers(key);
const usesLoopBinding = [...roots].some((name) => bound.has(name));
if (!usesLoopBinding) {
// A key that ignores the loop item is constant across iterations:
// guaranteed duplicate keys at mount.
return { unsupported: 'each key not derived from the loop item requires source-preview mode' };
}
if (key.type === 'Identifier' && bound.has(key.name)) return {};
if (
key.type === 'MemberExpression'
&& !key.computed
&& key.object?.type === 'Identifier'
&& bound.has(key.object.name)
&& key.property?.type === 'Identifier'
) {
return { keyField: key.property.name };
}
return { unsupported: 'complex each key requires source-preview mode' };
}
/**
* Describe a fragment's root element for browser presence probing:
* { tag, classes } of the first RegularElement, or null for text-only
* fragments (which cannot be probed reliably).
*/
function describeElementProbe(fragment) {
const rootEl = (fragment?.nodes || []).find((n) => n.type === 'RegularElement');
if (!rootEl) return null;
const classes = [];
for (const attr of rootEl.attributes || []) {
if (attr.type === 'Attribute' && attr.name === 'class' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part.type === 'Text') classes.push(...part.data.split(/\s+/).filter(Boolean));
}
}
}
return { tag: rootEl.name, classes };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Analyze a markup block and produce the prop-substituted scaffold markup and
* the v2 prop contract. Returns { ok: false, reason } when the block needs
* source-preview mode (parse failure or unsupported construct).
*/
export function analyzeSvelteMarkup(markup, parse) {
const source = String(markup || '');
let ast;
try {
ast = parse(source, { modern: true });
} catch (err) {
return { ok: false, reason: `svelte parse failed: ${err.message}` };
}
if (ast.instance || ast.module) {
return { ok: false, reason: 'selected block contains a script tag' };
}
const analysis = new Analysis(source);
analyzeFragment(ast.fragment, analysis, []);
if (analysis.unsupported) {
return { ok: false, reason: analysis.unsupported };
}
for (const entry of analysis.contract) {
if (entry.kind === 'collection' && entry.item?.nestedUnsupported) {
return { ok: false, reason: 'per-item content (nested blocks or expressions) this preview cannot hydrate requires source-preview mode' };
}
}
const markupWithProps = applyReplacements(source, analysis.replacements);
return {
ok: true,
markupWithProps,
contract: analysis.contract.map((entry) => ({
prop: entry.prop,
expr: entry.expr,
kind: entry.kind,
// Kept for backward compatibility with v1 consumers (fake e2e agent,
// text-only restore paths).
placeholder: `{${entry.expr}}`,
...(entry.item ? { item: entry.item } : {}),
...(entry.probe ? { probe: entry.probe } : {}),
})),
};
}
function applyReplacements(source, replacements) {
const sorted = [...replacements].sort((a, b) => b.start - a.start);
let out = source;
for (const { start, end, prop } of sorted) {
out = out.slice(0, start) + prop + out.slice(end);
}
return out;
}
/**
* Restore a variant's markup back to route-source form: every free
* identifier that matches a contract prop is replaced by its original
* expression. AST-based so `{#each stages as stage}` restores to
* `{#each data.stages as stage}` even though the prop appears without braces.
*/
export function restoreSvelteMarkup(markup, contract, parse) {
const source = String(markup || '');
const byProp = new Map();
for (const entry of contract || []) byProp.set(entry.prop, entry.expr);
if (byProp.size === 0) return { ok: true, markup: source };
let ast;
try {
ast = parse(source, { modern: true });
} catch (err) {
return { ok: false, reason: `variant parse failed: ${err.message}` };
}
const replacements = [];
const visitExpr = (expression, scopes) => {
if (!expression) return;
collectFreeIdentifierRanges(expression, scopes, (name, start, end) => {
const original = byProp.get(name);
if (original != null && original !== name) replacements.push({ start, end, prop: original });
});
};
const walk = (fragment, scopes) => {
const fragmentScope = new Set();
const nextScopes = [...scopes, fragmentScope];
for (const node of fragment?.nodes || []) {
if (node.type === 'ConstTag' && node.declaration) {
for (const decl of node.declaration.declarations || []) collectPatternNames(decl.id, fragmentScope);
}
}
for (const node of fragment?.nodes || []) {
switch (node?.type) {
case 'ExpressionTag':
case 'HtmlTag':
visitExpr(node.expression, nextScopes);
break;
case 'ConstTag':
for (const decl of node.declaration?.declarations || []) visitExpr(decl.init, nextScopes);
break;
case 'EachBlock': {
visitExpr(node.expression, nextScopes);
const bound = new Set();
if (node.context) collectPatternNames(node.context, bound);
if (node.index) bound.add(node.index);
// The key evaluates per item, so the loop context and index are in
// scope there. Visiting it with outer scopes only let a contract
// prop that shares a loop binding's name rewrite the key.
if (node.key) visitExpr(node.key, [...nextScopes, bound]);
walk(node.body, [...nextScopes, bound]);
if (node.fallback) walk(node.fallback, nextScopes);
break;
}
case 'IfBlock':
visitExpr(node.test, nextScopes);
walk(node.consequent, nextScopes);
if (node.alternate) walk(node.alternate, nextScopes);
break;
case 'KeyBlock':
visitExpr(node.expression, nextScopes);
walk(node.fragment, nextScopes);
break;
case 'SnippetBlock': {
const bound = new Set();
for (const param of node.parameters || []) collectPatternNames(param, bound);
walk(node.body, [...nextScopes, bound]);
break;
}
default: {
for (const attr of node?.attributes || []) {
if (attr.type === 'Attribute' && Array.isArray(attr.value)) {
for (const part of attr.value) {
if (part?.type === 'ExpressionTag') visitExpr(part.expression, nextScopes);
}
} else if (attr.expression) {
visitExpr(attr.expression, nextScopes);
}
}
if (node?.fragment) walk(node.fragment, nextScopes);
}
}
}
};
walk(ast.fragment, []);
return { ok: true, markup: applyReplacements(source, replacements) };
}
/**
* Report [name, start, end] for every free root identifier READ in an
* expression (skips member properties, object keys, shadowed names).
*/
function collectFreeIdentifierRanges(node, scopes, emit) {
const visit = (n, localBound) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { for (const item of n) visit(item, localBound); return; }
switch (n.type) {
case 'Identifier': {
const bound = localBound.has(n.name) || scopes.some((s) => s.has(n.name));
if (!bound) emit(n.name, n.start, n.end);
return;
}
case 'MemberExpression':
visit(n.object, localBound);
if (n.computed) visit(n.property, localBound);
return;
case 'Property':
if (n.computed) visit(n.key, localBound);
visit(n.value, localBound);
return;
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
const inner = new Set(localBound);
for (const param of n.params || []) collectPatternNames(param, inner);
visit(n.body, inner);
return;
}
default:
for (const key of Object.keys(n)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'parent') continue;
visit(n[key], localBound);
}
}
};
visit(node, new Set());
}
/**
* Build the preview component's script block from a v2 contract, with
* defaults that keep an unhydrated mount rendering instead of crashing.
*/
// `/** @type {...} */` directly before a destructuring declaration is also
// JSDoc's cast syntax, and Svelte 5.50+ re-emits the annotation in cast form
// onto the template's own declaration: `var /** @type {...} */ (h1) = root()`.
// That is a syntax error, so the browser's dynamic import of the variant dies
// with "Unexpected token '('" and nothing renders. `@typedef` carries the same
// shape without being a cast. Keep it a typedef;
// tests/live-svelte-props-script.test.mjs compiles what these builders emit
// and parses the result.
export function buildPropsScriptV2(contract) {
if (!contract || contract.length === 0) {
return '<script>\n /** @typedef {Record<string, never>} Props */\n let {} = $props();\n</script>\n';
}
const defaults = {
text: "''",
raw: "''",
condition: 'false',
collection: '[]',
handler: '() => {}',
};
const types = {
text: 'string',
raw: 'string',
condition: 'boolean',
collection: 'Array<Record<string, unknown>>',
handler: '() => void',
};
const names = contract
.map((c) => `${c.prop} = ${defaults[c.kind] ?? "''"}`)
.join(', ');
const typeFields = contract
.map((c) => ` ${c.prop}?: ${types[c.kind] ?? 'string'};`)
.join('\n');
return `<script>\n /** @typedef {{\n${typeFields}\n }} Props */\n let { ${names} } = $props();\n</script>\n`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
// Matches the import at ANY revision (or none). [ \t]* bounds only, never
// \s*: a greedy \s* after the statement swallowed the next line's
// indentation on removal, leaving a formatting scar in user layouts.
const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm;
/**
* The import specifier carries a token-derived revision query. The adapter
* component embeds the helper token, and Vite (client AND SSR) can keep
* serving a stale compiled module after the file is rewritten on a helper
* restart; the browser then requests /live.js with a rotated-out token and
* gets a 401 with no picker. A changed specifier is a different module id,
* which no cache survives.
*/
export function svelteRootImportLine(rev) {
if (!rev) return SVELTE_ROOT_IMPORT;
return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';";
}
export function svelteAdapterRev(token) {
if (!token) return null;
return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
}
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port), token);
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) });
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content, { rev = null } = {}) {
let out = String(content || '');
const importLine = svelteRootImportLine(rev);
if (!out.includes(importLine)) {
// An import at an older revision is replaced in place, keeping its
// indentation; only a layout with no impeccable import gets an insert.
let replaced = false;
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => {
if (replaced) return '';
replaced = true;
const indent = (line.match(/^[ \t]*/) || [''])[0];
return indent + importLine + '\n';
});
if (!replaced) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + importLine + out.slice(insertAt);
} else {
out = `<script>\n ${importLine}\n</script>\n\n` + out;
}
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, '');
out = out.replace(/<script>\s*<\/script>[ \t]*\r?\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port, token) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port, token), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port, token) {
const liveUrl = 'http://localhost:' + Number(port) + '/live.js'
+ (token ? '?token=' + encodeURIComponent(token) : '');
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = '${liveUrl}';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
script.onerror = () => console.error(
'[impeccable] live.js failed to load from ' + LIVE_URL
+ ' (helper down, or the token rotated while a stale adapter module was cached).'
+ ' Re-run the live boot, then reload this page.'
);
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
return firstExistingFile(cwd, [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
]) || 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
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, '\\$&');
}

View File

@@ -0,0 +1,259 @@
/**
* 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 { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
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 (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
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 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, '\\$&');
}

View File

@@ -0,0 +1,75 @@
/**
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
* chrome Live mounts on the user's page, with the element ids that make it up.
*
* Single source of truth, consumed by:
* - skill/scripts/live/browser-script-parts.mjs — serializes this into
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
* - skill/scripts/live-browser.js — publishes it on
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
* file is served raw and injected as a classic <script>, so it cannot
* import this module at runtime; it reads the injected global instead, the
* same path live/vocabulary.mjs already takes for the command palette.
* - the private impeccable-site repo — site/components/LiveUiGallery.astro
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
* fail the site build when the Live UI lab has no snapshot for a surface
* defined here. That guard only guards if it reads this list rather than a
* copy the site keeps, so this module must stay importable from Node.
* Renaming a key or the module is a breaking change for that build; the
* list was briefly inlined into live-browser.js and the site had to parse
* it back out with a regex.
*
* Add a surface here and both the browser bundle and the site lab follow.
*/
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
export const LIVE_UI_PREFIX = 'impeccable-live';
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
/**
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
],
},
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
{
key: 'element-selection-chrome',
ids: [
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
id('configure-voice'), id('configure-bar-tooltip'),
],
},
{ key: 'action-picker', ids: [id('picker')] },
{ key: 'edit-chrome', ids: [id('edit-badge')] },
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
{ key: 'variant-params-panel', ids: [id('params-panel')] },
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
{
key: 'insert-mode-chrome',
ids: [
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
],
},
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
{ key: 'design-system-panel', ids: [id('design-host')] },
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
{ key: 'css-isolation-boundary', ids: [id('root')] },
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
/** Every id any surface owns, de-duplicated, in surface order. */
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);

View File

@@ -0,0 +1,171 @@
/**
* Canonical design-command vocabulary for Live Mode: each command's value, human
* label, and SVG icon. Icons stack above the chip label; strokes use currentColor
* so the icon recolors when its chip is selected.
*
* Single source of truth, consumed by:
* - skill/scripts/live/event-validation.mjs — re-exports VISUAL_ACTIONS.
* - skill/scripts/live-browser.js — the real picker. It is served raw and
* injected as an IIFE, so it cannot import this at runtime; live-server.mjs
* serializes LIVE_COMMANDS into window.__IMPECCABLE_VOCAB__ alongside the
* token/port, and live-browser.js builds its ICONS + ACTIONS from that.
* - site/components/LiveDemoPalette.astro — the marketing demo palette (imported
* at build time).
*
* Add, rename, or reorder a verb here and all three follow.
*/
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
export const LIVE_COMMANDS = [
{ value: 'impeccable', label: 'Freeform', icon: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>` },
{ value: 'bolder', label: 'Bolder', icon: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>` },
{ value: 'quieter', label: 'Quieter', icon: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>` },
{ value: 'distill', label: 'Distill', icon: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>` },
{ value: 'polish', label: 'Polish', icon: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>` },
{ value: 'typeset', label: 'Typeset', icon: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>` },
{ value: 'colorize', label: 'Colorize', icon: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>` },
{ value: 'layout', label: 'Layout', icon: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>` },
{ value: 'adapt', label: 'Adapt', icon: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>` },
{ value: 'animate', label: 'Animate', icon: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>` },
{ value: 'delight', label: 'Delight', icon: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>` },
{ value: 'overdrive', label: 'Overdrive', icon: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>` },
];
// Action values accepted by the live event protocol, in palette order.
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
/*
* ---------------------------------------------------------------------------
* Protocol vocabulary
* ---------------------------------------------------------------------------
* The enums below are the wire contract between the browser overlay, the live
* helper server, and the durable session journal. They live here rather than in
* the modules that use them so a value cannot be added to the validator without
* the store and the server seeing it too.
*
* live-browser.js still cannot import this file (it is served raw and injected
* as an IIFE), so its local phase table repeats the agent-phase names. Anything
* the server can broadcast must appear in AGENT_PHASES here first.
*/
/**
* Phases the live server broadcasts as `agent_phase`, in lifecycle order.
* Every one of these is emitted by `recordAgentPhase()` in live-server.mjs;
* the validator rejects anything else, so a typo in a phase name fails loudly
* instead of quietly ranking as an unknown phase in the browser's progress bar.
*/
export const AGENT_PHASES = Object.freeze([
'picked_up',
'scaffolding',
'source_ready',
'scaffold_fallback',
'generation_ready',
'first_reviewable',
'second_reviewable',
'all_variants_ready',
]);
/** Event types the helper server accepts from the browser over POST /events. */
export const CLIENT_EVENT_TYPES = Object.freeze([
'generate',
'accept',
'discard',
'checkpoint',
'agent_phase',
'variant_mounted',
'variant_mount_failed',
'exit',
'prefetch',
'manual_edits',
'steer',
'carbonize_cleanup',
]);
/**
* Event types the durable journal applies. A superset of CLIENT_EVENT_TYPES:
* the agent-side helpers (live-poll, live-complete) and the server itself
* append the rest. An event type missing here lands as `unknown_event_type`
* in the snapshot diagnostics.
*/
export const JOURNAL_EVENT_TYPES = Object.freeze([
'generate',
'variant_plan',
'detector_waivers',
'agent_phase',
'variants_ready',
'agent_done',
'variant_mounted',
'variant_mount_failed',
'checkpoint',
'accept',
'accept_intent',
'manual_edit_apply',
'steer',
'steer_done',
'carbonize_cleanup',
'discard',
'discarded',
'complete',
'agent_error',
]);
/** Phases the session store assigns to a snapshot. */
export const SESSION_PHASES = Object.freeze([
'new',
'generate_requested',
'variants_ready',
'carbonize_required',
'carbonize_cleanup_requested',
'manual_edit_apply_requested',
'steer_requested',
'steer_done',
'accept_requested',
'discard_requested',
'discarded',
'completed',
'agent_error',
]);
/** Phases that retire a session from the active list. */
export const COMPLETED_SESSION_PHASES = Object.freeze(['completed', 'discarded']);
/**
* Phases after which a late generation write is a ghost from a canceled cycle.
* The store journals such an event as a diagnostic instead of applying it.
*/
export const GENERATION_FENCED_SESSION_PHASES = Object.freeze([
'accept_requested',
'discard_requested',
'carbonize_required',
'completed',
'discarded',
]);
/**
* `reason` values carried on checkpoint events. Not validated (an unknown
* reason is journaled, never rejected) because the reason is diagnostic
* breadcrumb, not control flow. Two exceptions drive behavior and are split
* out below.
*/
export const CHECKPOINT_REASONS = Object.freeze([
'generate_started',
'variants_progress',
'variants_ready',
'browser_resumed',
'browser_resumed_svelte_component',
'param_changed',
'variant_anchor_missing',
'component_preview_anchor_missing',
'steer_input_focused',
'steer_submitted',
'steer_send_failed',
'steer_done',
'steer_error',
]);
/** Checkpoint reasons the server reads as variant-publication progress. */
export const VARIANT_PROGRESS_CHECKPOINT_REASONS = Object.freeze([
'variants_progress',
'variants_ready',
]);